Skip to content

Commit 757a9a9

Browse files
authored
Mock in more environments (#1778)
* In package environment for interactive use * In actual test environment for testing + S3 support * In S3 methods table And no longer mock in base environment.
1 parent b80234b commit 757a9a9

File tree

8 files changed

+258
-64
lines changed

8 files changed

+258
-64
lines changed

NEWS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
* New `vignette("special-files")` describes the various special files
2121
that testthat uses (#1638).
2222

23-
* `with_mocked_bindings()` and `local_mocked_bindings()` can now bind in the
24-
imports namespace too. This changes makes them very close in capability
25-
for `with_mock()` and `local_mock()` so those functions now recommend
26-
`with_mocked_bindings()` and `local_mocked_bindings()` rather than functions
27-
from the mockr/mockery packages.
23+
* `with_mocked_bindings()` and `local_mocked_bindings()` now also bind in the
24+
imports namespace and can mock S3 methods. These changes make them good
25+
substitutes for the deprecated functions `with_mock()` and `local_mock()`, so
26+
those older functions now recommend switching to the newer equivalents
27+
instead of using the mockr or mockery packages.
2828

2929
# testthat 3.1.7
3030

R/mock2.R

Lines changed: 128 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,96 @@
33
#' @description
44
#' `r lifecycle::badge("experimental")`
55
#'
6+
#' `with_mocked_bindings()` and `local_mocked_bindings()` provide tools for
7+
#' "mocking", temporarily redefining a function so that it behaves differently
8+
#' during tests. This is helpful for testing functions that depend on external
9+
#' state (i.e. reading a value from a file or a website, or pretending a package
10+
#' is or isn't installed).
11+
#'
612
#' These functions represent a second attempt at bringing mocking to testthat,
7-
#' incorporating what we've learned from the mockr, mockery, and mockthat package.
13+
#' incorporating what we've learned from the mockr, mockery, and mockthat
14+
#' packages.
15+
#'
16+
#' # Use
17+
#'
18+
#' There are four places that the function you are trying to mock might
19+
#' come from:
20+
#'
21+
#' * Internal to your package.
22+
#' * Imported from an external package via the `NAMESPACE`.
23+
#' * The base environment.
24+
#' * Called from an external package with `::`.
25+
#'
26+
#' They are described in turn below.
27+
#'
28+
#' ## Internal & imported functions
29+
#'
30+
#' You mock internal and imported functions the same way. For example, take
31+
#' this code:
32+
#'
33+
#' ```R
34+
#' some_function <- function() {
35+
#' another_function()
36+
#' }
37+
#' ```
38+
#'
39+
#' It doesn't matter whether `another_function()` is defined by your package
40+
#' or you've imported it from a dependency with `@import` or `@importFrom`,
41+
#' you mock it the same way:
42+
#'
43+
#' ```R
44+
#' local_mocked_bindings(
45+
#' another_function = function(...) "new_value"
46+
#' )
47+
#' ```
48+
#'
49+
#' ## Base functions
50+
#'
51+
#' Note that it's not possible to mock functions in the base namespace
52+
#' (i.e. functions that you can use without explicitly importing them)
53+
#' since currently we don't know of a way to to mock them without potentially
54+
#' affecting all running code. If you need to mock a base function, you'll
55+
#' need to create a wrapper, as described below.
56+
#'
57+
#' ## Namespaced calls
58+
#'
59+
#' It's trickier to mock functions in other packages that you call with `::`.
60+
#' For example, take this minor variation:
861
#'
9-
#' `with_mocked_bindings()` and `local_mocked_bindings()` work by temporarily
10-
#' changing variable bindings in the namespace of namespace `.package`.
11-
#' Generally, it's only safe to mock packages that you own. If you mock other
12-
#' packages, we recommend using `skip_on_cran()` to avoid CRAN failures if the
13-
#' implementation changes.
62+
#' ```R
63+
#' some_function <- function() {
64+
#' anotherpackage::another_function()
65+
#' }
66+
#' ```
1467
#'
15-
#' These functions do not currently affect registered S3 methods.
68+
#' To mock here, you'll need to modify `another_function()` inside the
69+
#' `anotherpackage` package. You _can_ do this by supplying the `.package`
70+
#' argument:
1671
#'
72+
#' ```R
73+
#' local_mocked_bindings(
74+
#' another_function = function(...) "new_value",
75+
#' .package = "anotherpackage"
76+
#' )
77+
#' ```
78+
#'
79+
#' But it's not a great idea to mock a namespace that you don't own because
80+
#' it affects all code in that package, not just code in your package. Instead,
81+
#' it's safer to either import the function into your package, or make a wrapper
82+
#' that you can mock:
83+
#'
84+
#' ```R
85+
#' some_function <- function() {
86+
#' my_wrapper()
87+
#' }
88+
#' my_wrapper <- function(...) {
89+
#' anotherpackage::another_function(...)
90+
#' }
91+
#'
92+
#' local_mocked_bindings(
93+
#' my_wrapper = function(...) "new_value"
94+
#' )
95+
#' ```
1796
#' @export
1897
#' @param ... Name-value pairs providing functions to mock.
1998
#' @param code Code to execute with specified bindings.
@@ -30,15 +109,26 @@ local_mocked_bindings <- function(..., .package = NULL, .env = caller_env()) {
30109
.package <- .package %||% dev_package()
31110
ns_env <- ns_env(.package)
32111

33-
# Rebind, first looking in package namespace, then imports, then the base
34-
# namespace, then the global environment
35-
envs <- c(list(ns_env), env_parents(ns_env))
112+
# Rebind in namespace, imports, and the global environment
113+
envs <- list(ns_env, env_parent(ns_env), globalenv())
36114
bindings_found <- rep_named(names(bindings), FALSE)
37115
for (env in envs) {
38-
this_bindings <- env_has(env, names(bindings)) & !bindings_found
116+
local_bindings_rebind(!!!bindings, .env = env, .frame = .env)
117+
bindings_found <- bindings_found | env_has(env, names(bindings))
118+
}
39119

40-
local_bindings_unlock(!!!bindings[this_bindings], .env = env, .frame = .env)
41-
bindings_found <- bindings_found | this_bindings
120+
# And mock S3 methods
121+
methods_env <- ns_env[[".__S3MethodsTable__."]]
122+
local_bindings_rebind(!!!bindings, .env = methods_env, .frame = .env)
123+
124+
# If needed, also mock in the package environment so we can call directly
125+
if (is_attached(paste0("package:", .package))) {
126+
local_bindings_rebind(!!!bindings, .env = pkg_env(.package), .frame = .env)
127+
}
128+
# And in the current testing environment
129+
test_env <- testthat_env$current_test_env
130+
if (!is.null(test_env)) {
131+
local_bindings_rebind(!!!bindings, .env = test_env, .frame = .env)
42132
}
43133

44134
if (any(!bindings_found)) {
@@ -58,10 +148,13 @@ with_mocked_bindings <- function(code, ..., .package = NULL) {
58148

59149
# helpers -----------------------------------------------------------------
60150

61-
# Wrapper around local_bindings() that automatically unlocks and takes
62-
# list of bindings.
63-
local_bindings_unlock <- function(..., .env = .frame, .frame = caller_env()) {
151+
# Wrapper around local_bindings() that only rebinds existing values,
152+
# automatically unlocking as needed. We can only rebind because most of
153+
# these environments are locked, meaning we can't add new bindings.
154+
local_bindings_rebind <- function(..., .env = .frame, .frame = caller_env()) {
64155
bindings <- list2(...)
156+
bindings <- bindings[env_has(.env, names(bindings))]
157+
65158
if (length(bindings) == 0) {
66159
return()
67160
}
@@ -110,21 +203,21 @@ check_bindings <- function(x, error_call = caller_env()) {
110203

111204
# For testing -------------------------------------------------------------
112205

113-
test_mock_package <- function() {
114-
test_mock_package2()
206+
test_mock_direct <- function() {
207+
"y"
115208
}
116-
test_mock_package2 <- function() "y"
117209

118-
test_mock_base <- function() {
119-
identity("y")
210+
test_mock_internal <- function() {
211+
test_mock_internal2()
120212
}
213+
test_mock_internal2 <- function() "y"
121214

122215
test_mock_imports <- function() {
123-
as.character(sym("x"))
216+
as.character(sym("y"))
124217
}
125218

126219
test_mock_namespaced <- function() {
127-
as.character(rlang::sym("x"))
220+
as.character(rlang::sym("y"))
128221
}
129222

130223
test_mock_method <- function(x) {
@@ -134,3 +227,15 @@ test_mock_method <- function(x) {
134227
test_mock_method.integer <- function(x) {
135228
"y"
136229
}
230+
231+
232+
show_bindings <- function(name, env = caller_env()) {
233+
envs <- env_parents(env)
234+
has_binding <- Filter(function(env) env_has(env, name), envs)
235+
lapply(has_binding, env_desc)
236+
invisible()
237+
}
238+
239+
env_desc <- function(env) {
240+
cat(obj_address(env), ": ", env_name(env), "\n", sep = "")
241+
}

R/parallel.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ queue_process_setup <- function(test_package, test_dir, load_helpers, load_packa
222222
test_dir,
223223
load_package
224224
)
225+
# record testing env for mocks
226+
local_bindings(current_test_env = env, .env = testthat_env)
227+
225228
asNamespace("testthat")$test_files_setup_state(
226229
test_dir = test_dir,
227230
test_package = test_package,

R/skip.R

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,16 @@ skip_on_bioc <- function() {
202202
#' @rdname skip
203203
skip_if_translated <- function(msgid = "'%s' not found") {
204204
skip_if(
205-
gettext(msgid, domain = "R") != msgid,
205+
gettext(msgid) != msgid,
206206
paste0("\"", msgid, "\" is translated")
207207
)
208208
}
209209

210+
gettext <- function(msgid, domain = "R") {
211+
base::gettext(msgid, domain = domain)
212+
}
213+
214+
210215
#' Superseded skip functions
211216
#'
212217
#' @description

R/test-files.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ test_files_serial <- function(test_dir,
190190
load_package = c("none", "installed", "source")) {
191191

192192
env <- test_files_setup_env(test_package, test_dir, load_package, env)
193+
# record testing env for mocks
194+
local_bindings(current_test_env = env, .env = testthat_env)
195+
193196
test_files_setup_state(test_dir, test_package, load_helpers, env)
194197
reporters <- test_files_reporter(reporter)
195198

man/local_mocked_bindings.Rd

Lines changed: 85 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-mock.R

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ test_that("can change value of internal function", {
77
local_edition(2)
88

99
with_mock(
10-
test_mock_package2 = function() 5,
11-
expect_equal(test_mock_package(), 5)
10+
test_mock_internal2 = function() 5,
11+
expect_equal(test_mock_internal(), 5)
1212
)
1313

1414
# and value is restored on error
1515
expect_error(
1616
with_mock(
17-
test_mock_package2 = function() 5,
17+
test_mock_internal2 = function() 5,
1818
stop("!")
1919
)
2020
)
21-
expect_equal(test_mock_package(), "y")
21+
expect_equal(test_mock_internal(), "y")
2222
})
2323

2424

@@ -27,8 +27,8 @@ test_that("mocks can access local variables", {
2727
x <- 5
2828

2929
with_mock(
30-
test_mock_package2 = function() x,
31-
expect_equal(test_mock_package(), 5)
30+
test_mock_internal2 = function() x,
31+
expect_equal(test_mock_internal(), 5)
3232
)
3333
})
3434

0 commit comments

Comments
 (0)