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 stublocal_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.
Related
#Clean #tests #Local_Mocked_Bindings #dependency #packaging #RBloggers


