Clean R tests with `Local_Mocked_Bindings` and dependency packaging | R-Bloggers

Clean R tests with `Local_Mocked_Bindings` and dependency packaging | R-Bloggers

[This article was first published on jakub::sobolewski, and kindly contributed to R-bloggers]. (You can report problems here about the content on this page)


Do you want to share your content on R-bloggers? Click here if you have a blog, or here If you don’t.

Testing functions that depend on external dependencies are difficult.

Your tests become slow, vulnerable and unreliable when they depend on external APIs, file systems or services. Even worse, some dependencies like it Sys.time() Return values ​​that constantly change, making consistent testing almost impossible.

The solution is simple: wrap external dependencies in your own functions and blunt them with testthat::local_mocked_bindings.

Why pack external dependencies?

External dependencies make testing in three ways painful:

  • First, they can be unpredictable. APIs go down. Change file systems. Network application time -out.
  • Secondly, they can be uncontrollable. You can’t force Sys.time() To return a specific value or to make any API response predictable.
  • Third, they can be slow. Real database questions and HTTP requests add seconds to test suites that must be performed in milliseconds.

Reflive dependence solves all three problems at the same time.

The pattern: wrap, stub, test

Here you can read how you can build testable functions with external dependencies:

Step 1: wrap external calls

Instead of calling Sys.time() Immediately create a wrapper function:

get_current_time <- function() {
  Sys.time()
}

calculate_elapsed_time <- function(start_time) {
  current <- get_current_time()
  difftime(current, start_time, units = "secs")
}

Step 2: Test with Stubs

Usage local_mocked_bindings To replace your wrap with a predictable stub:

test_that("calculate_elapsed_time returns time difference", {
  # Arrange
  local_mocked_bindings(
    get_current_time = function() as.POSIXct("2023-01-01 12:30:00")
  )
  start_time <- as.POSIXct("2023-01-01 12:00:00")

  # Act
  result <- calculate_elapsed_time(start_time)

  # Assert
  expect_equal(as.numeric(result), 1800) # 30 minutes = 1800 seconds
})

See how clean that test is. No setup. No turn. No flaky timing problems.

Why local_mocked_bindings Encourages a good design

I only used for a long time mockery For stump, but local_mocked_bindings starts to grow on me.

The most important insight: you get the most out local_mocked_bindings If you have the .package argument.

I bet that this was deliberate design of this interface: you will receive the cleanest test code if you do not use extra arguments. Without .package You can only stimulate functions that are defined in the current name space. This forces you to apply good design principles: wrap external dependencies in your own functions.

Try to bump Sys.time Direct and you need:

# Messy - requires .package argument
local_mocked_bindings(
  Sys.time = function() as.POSIXct("2023-01-01 12:30:00"),
  .package = "base"
)

But wrap it first:

# Clean - no .package needed
local_mocked_bindings(
  get_current_time = function() as.POSIXct("2023-01-01 12:30:00")
)

The function teaches you better design (if you pay attention).

Three benefits of packing

Packing external dependencies gives you three powerful options:

  • Stubbing for testing: Replace unpredictable external calls with controlled test doubles with the help of local_mocked_bindings.
  • Dependency injection: It opens doors for injecting different implementations for different environments. Maybe production has been used Sys.time() But your staging environment reads from a Mock Time Server. Then we can use dependency injection of a fake in Tests, or still use a stub local_mocked_bindings.
  • Easy migration: Change implementations without touching the call code. Today you can read the time of the system clock, tomorrow of a sun clock camera, next week of an atomic time -Api.

The Sys.time() Problem

Sys.time() illustrates why packing things.

Unlike random numbers (controlled by set.seed()), time always changes. Each test run gets different values. You cannot make time -dependent functions deterministic without replacing the time source.

Consider a function that calculates office hours:

is_business_hour <- function() {
  current_hour <- hour(Sys.time())
  current_hour >= 9 && current_hour <= 17
}

How do you test this? You cannot determine when your tests will be performed.

Wrap the time dependence:

get_current_time <- function() {
  Sys.time()
}

is_business_hour <- function() {
  current_hour <- hour(get_current_time())
  current_hour >= 9 && current_hour <= 17
}

Now testing is becoming trivial:

test_that("is_business_hour returns TRUE during business hours", {
  # Arrange
  local_mocked_bindings(
    get_current_time = function() as.POSIXct("2023-01-01 14:00:00") # 2 PM
  )

  # Act
  result <- is_business_hour()

  # Act
  expect_true(result)
})

test_that("is_business_hour returns FALSE outside business hours", {
  # Arrange
  local_mocked_bindings(
    get_current_time = function() as.POSIXct("2023-01-01 22:00:00") # 10 PM
  )

  # Act
  result <- is_business_hour()

  # Assert
  expect_false(result)
})

Perfect control. Perfect reliability.

Real example

Here is how the pattern works with more complex dependencies:

# Wrapper functions for external dependencies
get_system_info <- function() {
  Sys.info()
}

get_package_versions <- function(path) {
  if (!rlang::is_installed("yaml")) {
    stop("Packages \"yaml\" not installed", call. = FALSE)
  }
  if (!rlang::is_installed("here")) {
    stop("Package \"here\" not installed", call. = FALSE)
  }
  yaml::read_yaml(here::here(path, "renv.lock"))
}

get_test_results <- function(...) {
  testthat::test_local(..., stop_on_failure = FALSE)
}

# Function that uses wrapped dependencies
generate_system_report <- function(project_path = ".") {
  system_info <- get_system_info()
  packages <- get_package_versions(project_path)
  tests <- get_test_results(project_path)

  list(
    os = system_info[["sysname"]],
    r_version = system_info[["version"]],
    package_count = length(packages$Packages),
    test_status = all(tests$passed)
  )
}

Testing becomes simple:

test_that("generate_system_report creates complete report", {
  # Arrange
  local_mocked_bindings(
    get_system_info = function() c(sysname = "Linux", version = "4.0.0"),
    get_package_versions = function(path) list(Packages = list(a = 1, b = 2)),
    get_test_results = function(...) data.frame(passed = c(TRUE, TRUE))
  )

  # Act
  report <- generate_system_report()

  # Assert
  expect_equal(report$os, "Linux")
  expect_equal(report$package_count, 2)
  expect_true(report$test_status)
})

Three external dependencies checked with three simple stubs. No real access system access. No actual test version. No system introspection.

Interface about implementation

The wrapper pattern creates an interface between your code and external dependencies.

Interfaces are powerful because they “get” some “from” how “. Your code knows what it needs (current time, system information, test results) but does not matter how those needs are met.

In production, get_current_time() to call to action Sys.time(). In tests it returns a fixed time stamp. In a specialized environment you can read from a network -time protocol server or even that sun clock camera.

Change the implementation without changing a single rule of on -call code.

Win clean tests

Compare these two approaches:

Without wrapping in:

# Brittle, slow, unpredictable
test_that("time calculation works", {
  start <- Sys.time()
  Sys.sleep(0.1)
  result <- calculate_duration(start)
  expect_gt(result, 0.1) # Flaky assertion
})

With packing:

# Reliable, fast, predictable
test_that("calculate_duration returns time difference between start and current time", {
  # Arrange
  local_mocked_bindings(
    get_current_time = function() as.POSIXct("2023-01-01 12:30:00")
  )
  start <- as.POSIXct("2023-01-01 12:00:00")

  # Act
  result <- calculate_duration(start)

  # Assert
  expect_equal(result, 1800)
})

The second test is carried out in microseconds, never fails randomly and clearly expresses the intention.

Start wrapping today

The next time you write a function that touches the outside world, wrap the external call. You will thank you your future tests yourself.

The pattern is simple: wrap external dependencies, stump in tests, enjoy clean and reliable test suites that work quickly and pass consistently.


#Clean #tests #Local_Mocked_Bindings #dependency #packaging #RBloggers

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *