The mockr package helps testing code that relies on functions that are slow, have unintended side effects or access resources that may not be available when testing. It allows replacing such functions with deterministic mock functions. This article gives an overview and introduces a few techniques.
Let’s assume a function access_resource()
that accesses
some resource. This works in normal circumstances, but not during tests.
A function work_with_resource()
works with that resource.
How can we test work_with_resource()
without adding too
much logic to the implementation?
access_resource <- function() {
message("Trying to access resource...")
# For some reason we can't access the resource in our tests.
stop("Can't access resource now.")
}
work_with_resource <- function() {
resource <- access_resource()
message("Fetched resource: ", resource)
invisible(resource)
}
In our example, calling the worker function gives an error:
work_with_resource()
#> Trying to access resource...
#> Error in access_resource(): Can't access resource now.
We can use local_mock()
to temporarily replace the
implementation of access_resource()
with one that doesn’t
throw an error:
access_resource_for_test <- function() {
# We return a value that's good enough for testing
# and can be computed quickly:
42
}
local({
# Here, we override the function that raises the error
local_mock(access_resource = access_resource_for_test)
work_with_resource()
})
#> Fetched resource: 42
The use of local()
here is required for technical
reasons. This package is most useful in conjunction with testthat, the
remainder of this article will focus on that use case.
We create a package called {mocktest} for demonstration. For this
demo, the package is created in a temporary directory. A real project
will live somewhere in your home directory. The
usethis::create_package()
function sets up a package
project ready for development. The output shows the details of the
package created.
pkg <- usethis::create_package(file.path(tempdir(), "mocktest"))
#> ✔ Creating '/tmp/Rtmp5xM5LB/mocktest/'.
#> ✔ Setting active project to "/tmp/Rtmp5xM5LB/mocktest".
#> ✔ Creating 'R/'.
#> ✔ Writing 'DESCRIPTION'.
#> Package: mocktest
#> Title: What the Package Does (One Line, Title Case)
#> Version: 0.0.0.9000
#> Authors@R (parsed):
#> * First Last <first.last@example.com> [aut, cre] (YOUR-ORCID-ID)
#> Description: What the package does (one paragraph).
#> License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a
#> license
#> Encoding: UTF-8
#> Roxygen: list(markdown = TRUE)
#> RoxygenNote: 7.3.2.9000
#> ✔ Writing 'NAMESPACE'.
#> ✔ Setting active project to "<no active project>".
In an interactive RStudio session, a new window opens. Users of other environments would change the working directory manually. For this demo, we manually set the active project.
usethis::proj_set()
#> ✔ Setting active project to "/tmp/Rtmp5xM5LB/mocktest".
The infrastructure files and directories that comprise a minimal R package are created:
fs::dir_tree()
#> .
#> ├── DESCRIPTION
#> ├── NAMESPACE
#> └── R
We copy the functions from the previous example (under different names) into the package. Normally we would use a text editor:
cat > R/resource.R <<"EOF"
access_resource_pkg <- function() {
message("Trying to access resource...")
# For some reason we can't access the resource in our tests.
stop("Can't access resource now.")
}
work_with_resource_pkg <- function() {
resource <- access_resource_pkg()
message("Fetched resource: ", resource)
invisible(resource)
}
EOF
Loading the package and calling the function gives the error we have seen before:
pkgload::load_all()
#> ℹ Loading mocktest
work_with_resource_pkg()
#> Trying to access resource...
#> Error in access_resource_pkg(): Can't access resource now.
We create a test that tests work_with_resource_pkg()
,
mocking access_resource_pkg()
. We need to prefix with the
package name, because testthat provides its own
testthat::local_mock()
which is now deprecated.
usethis::use_testthat()
#> ✔ Adding testthat to Suggests field in DESCRIPTION.
#> ✔ Adding "3" to Config/testthat/edition.
#> ✔ Creating tests/testthat/.
#> ✔ Writing tests/testthat.R.
#> ☐ Call `usethis::use_test()` to initialize a basic test file and open it for
#> editing.
cat > tests/testthat/test-resource.R <<"EOF"
test_that("Can work with resource", {
mockr::local_mock(access_resource_pkg = function() {
42
})
expect_message(
expect_equal(work_with_resource_pkg(), 42)
)
})
EOF
The test succeeds:
testthat::test_local(reporter = "location")
#>
#> Attaching package: 'testthat'
#> The following objects are masked from 'package:mockr':
#>
#> local_mock, with_mock
#> Start test: Can work with resource
#> 'test-resource.R:6:3' [success]
#> 'test-resource.R:6:3' [success]
#> End test: Can work with resource
mockr is aware of testthat and will work even if executing the tests in the current session. This is especially handy if you want to troubleshoot single tests:
test_that("Can work with resource", {
mockr::local_mock(access_resource_pkg = function() {
42
})
expect_message(
expect_equal(work_with_resource_pkg(), 42)
)
})
#> Test passed
mockr can only mock functions in the current package. To substitute implementations of functions in other packages, create wrappers in your package and use these wrappers exclusively.
The example below demonstrates a d6()
function that is
used to get the value of a random die throw. Instead of using
runif()
directly, this function uses
my_runif()
which wraps runif()
.
cat > R/runif.R <<"EOF"
my_runif <- function(...) {
runif(...)
}
d6 <- function() {
trunc(my_runif(1, 0, 6)) + 1
}
EOF
pkgload::load_all()
#> ℹ Loading mocktest
This allows testing the behavior of d6()
:
test_that("d6() works correctly", {
seq <- c(0.32, 5.4, 5, 2.99)
my_runif_mock <- function(...) {
on.exit(seq <<- seq[-1])
seq[[1]]
}
mockr::local_mock(my_runif = my_runif_mock)
expect_equal(d6(), 1)
expect_equal(d6(), 6)
expect_equal(d6(), 6)
expect_equal(d6(), 3)
})
#> Test passed
mockr cannot substitute implementations of S3 methods. To substitute
methods for a class "foo"
, implement a subclass and add new
methods only for that subclass. The pillar package contains an
example where a class with changed behavior for dim()
and head()
for the sole purpose of testing.