Skip to content

Commit 253c882

Browse files
authored
Re-enter the mocking game (#1739)
1 parent 9cd6e01 commit 253c882

File tree

8 files changed

+198
-1
lines changed

8 files changed

+198
-1
lines changed

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,4 @@ Config/testthat/edition: 3
5656
Config/testthat/start-first: watcher, parallel*
5757
Encoding: UTF-8
5858
Roxygen: list(markdown = TRUE, r6 = FALSE)
59-
RoxygenNote: 7.2.1.9000
59+
RoxygenNote: 7.2.3

NAMESPACE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ S3method(format,expectation_success)
1515
S3method(format,mismatch_character)
1616
S3method(format,mismatch_numeric)
1717
S3method(is_informative_error,default)
18+
S3method(mockable_generic,integer)
1819
S3method(output_replay,character)
1920
S3method(output_replay,error)
2021
S3method(output_replay,message)
@@ -148,6 +149,7 @@ export(is_testing)
148149
export(is_true)
149150
export(local_edition)
150151
export(local_mock)
152+
export(local_mocked_bindings)
151153
export(local_reproducible_output)
152154
export(local_snapshotter)
153155
export(local_test_context)
@@ -210,6 +212,7 @@ export(use_catch)
210212
export(verify_output)
211213
export(watch)
212214
export(with_mock)
215+
export(with_mocked_bindings)
213216
export(with_reporter)
214217
import(rlang)
215218
importFrom(R6,R6Class)

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# testthat (development version)
22

3+
* Experimental new `with_mocked_bindings()` and `local_mocked_bindings()`
4+
(#1739).
5+
36
# testthat 3.1.6
47

58
* The embedded version of Catch no longer uses `sprintf()`.

R/mock2.R

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#' Mocking tools
2+
#'
3+
#' @description
4+
#' `r lifecycle::badge("experimental")`
5+
#'
6+
#' These functions represent a second attempt at bringing mocking to testthat,
7+
#' incorporating what we've learned from the mockr, mockery, and mockthat package.
8+
#'
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.
14+
#'
15+
#' These functions do not currently affect registered S3 methods.
16+
#'
17+
#' @export
18+
#' @param ... Name-value pairs providing functions to mock.
19+
#' @param code Code to execute with specified bindings.
20+
#' @param .env Environment that defines effect scope. For expert use only.
21+
#' @param .package The name of the package where mocked functions should be
22+
#' inserted. Generally, you should not need to supply this as it will be
23+
#' automatically detected when whole package tests are run or when there's
24+
#' one package under active development (i.e. loaded with
25+
#' [pkgload::load_all()]).
26+
local_mocked_bindings <- function(..., .package = NULL, .env = caller_env()) {
27+
bindings <- list2(...)
28+
check_bindings(bindings)
29+
30+
.package <- .package %||% dev_package()
31+
ns_env <- ns_env(.package)
32+
33+
# Unlock bindings and set values
34+
nms <- names(bindings)
35+
locked <- env_binding_unlock(ns_env, nms)
36+
withr::defer(env_binding_lock(ns_env, nms[locked]), envir = .env)
37+
local_bindings(!!!bindings, .env = ns_env, .frame = .env)
38+
39+
invisible()
40+
}
41+
42+
#' @rdname local_mocked_bindings
43+
#' @export
44+
with_mocked_bindings <- function(code, ..., .package = NULL) {
45+
local_mocked_bindings(..., .package = .package)
46+
code
47+
}
48+
49+
# helpers -----------------------------------------------------------------
50+
51+
dev_package <- function() {
52+
if (is_testing() && testing_package() != "") {
53+
testing_package()
54+
} else {
55+
loaded <- loadedNamespaces()
56+
is_dev <- map_lgl(loaded, function(x) !is.null(pkgload::dev_meta(x)))
57+
if (sum(is_dev) == 0) {
58+
cli::cli_abort("No packages loaded with pkgload")
59+
} else if (sum(is_dev) == 1) {
60+
loaded[is_dev]
61+
} else {
62+
cli::cli_abort("Multiple packages loaded with pkgload")
63+
}
64+
}
65+
}
66+
67+
check_bindings <- function(x, error_call = caller_env()) {
68+
if (!is_named(x)) {
69+
cli::cli_abort(
70+
"All elements of {.arg ...} must be named.",
71+
call = error_call
72+
)
73+
}
74+
75+
is_fun <- map_lgl(x, is.function)
76+
if (!any(is_fun)) {
77+
cli::cli_abort(
78+
"All elements of {.arg ...} must be functions.",
79+
call = error_call
80+
)
81+
}
82+
}
83+
84+
# In package
85+
mockable_generic <- function(x) {
86+
UseMethod("mockable_generic")
87+
}
88+
#' @export
89+
mockable_generic.integer <- function(x) {
90+
1
91+
}

_pkgdown.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ reference:
6464
contents:
6565
- ends_with("Reporter")
6666

67+
- title: Mocking
68+
contents:
69+
- with_mocked_bindings
70+
6771
- title: Expectation internals
6872
contents:
6973
- expect

man/local_mocked_bindings.Rd

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

tests/testthat/_snaps/mock2.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# with_mocked_bindings() validates its inputs
2+
3+
Code
4+
with_mocked_bindings(1 + 1, function() 2)
5+
Condition
6+
Error in `local_mocked_bindings()`:
7+
! All elements of `...` must be named.
8+
Code
9+
with_mocked_bindings(1 + 1, x = 2)
10+
Condition
11+
Error in `local_mocked_bindings()`:
12+
! All elements of `...` must be functions.
13+

tests/testthat/test-mock2.R

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
test_that("with_mocked_bindings affects local bindings", {
2+
out <- with_mocked_bindings(test_mock1(), test_mock2 = function() "x")
3+
expect_equal(out, "x")
4+
5+
expect_equal(test_mock1(), 10)
6+
})
7+
8+
test_that("local_mocked_bindings affects local bindings", {
9+
local({
10+
local_mocked_bindings(test_mock2 = function() "x")
11+
expect_equal(test_mock1(), "x")
12+
})
13+
14+
expect_equal(test_mock1(), 10)
15+
})
16+
17+
test_that("local_mocked_bindings affects S3 methods", {
18+
skip("currently fails")
19+
20+
local({
21+
local_mocked_bindings(mockable_generic.integer = function(x) 2)
22+
expect_equal(mockable_generic(1L), 2)
23+
})
24+
expect_equal(mockable_generic(1L), 1)
25+
})
26+
27+
test_that("can make wrapper", {
28+
local_mock_x <- function(env = caller_env()) {
29+
local_mocked_bindings(test_mock2 = function() "x", .env = env)
30+
}
31+
32+
local({
33+
local_mock_x()
34+
expect_equal(test_mock1(), "x")
35+
})
36+
37+
expect_equal(test_mock1(), 10)
38+
})
39+
40+
test_that("with_mocked_bindings() validates its inputs", {
41+
expect_snapshot(error = TRUE, {
42+
with_mocked_bindings(1 + 1, function() 2)
43+
with_mocked_bindings(1 + 1, x = 2)
44+
})
45+
})

0 commit comments

Comments
 (0)