Skip to content

Commit 66ddd0c

Browse files
authored
Enhance OO mocking (#2303)
Make it possible to mock methods that don't exist, and make it possible to mock the absence of a method. Fixes #2302
1 parent f920707 commit 66ddd0c

File tree

5 files changed

+122
-35
lines changed

5 files changed

+122
-35
lines changed

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# testthat 3.3.1
22

3+
* `local_mocked_s3_method()` and `local_mocked_s4_method()` can now mock methods that don't already exist, and can use `definition = NULL` to temporarily remove a method. `local_mocked_s4_method()` now also works when the generic is defined in another package (#2302).
34
* `expect_snapshot()` now reports the original error class for base errors, rather than `rlang_error` (#2286).
45
* `expect_success()` and `expect_failure()` are more clear about what the expectation actually did (#2297).
56
* The hint to use `snapshot_download_gh()` is now only emitted when running in a job named "R-CMD-check" (#2300).

R/mock-oo.R

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
#' Mock S3 and S4 methods
22
#'
33
#' @description
4-
#' These functions allow you to temporarily override S3 and S4 methods that
5-
#' already exist. It works by using [registerS3method()]/[setMethod()] to
6-
#' temporarily replace the original definition.
4+
#' These functions temporarily override S3 or S4 methods. They can mock
5+
#' methods that don't already exist, or temporarily remove a method by setting
6+
#' `definition = NULL`.
77
#'
88
#' Learn more about mocking in `vignette("mocking")`.
99
#'
1010
#' @param generic A string giving the name of the generic.
1111
#' @param signature A character vector giving the signature of the method.
12-
#' @param definition A function providing the method definition.
12+
#' @param definition A function providing the method definition, or `NULL` to
13+
#' temporarily remove the method.
1314
#' @param frame Calling frame which determines the scope of the mock.
1415
#' Only needed when wrapping in another local helper.
1516
#' @export
@@ -29,16 +30,41 @@ local_mocked_s3_method <- function(
2930
) {
3031
check_string(generic)
3132
check_string(signature)
32-
check_function(definition)
33+
check_function(definition, allow_null = TRUE)
3334

3435
old <- utils::getS3method(generic, signature, optional = TRUE)
36+
37+
# Set the new method, or a pass-through stub if removing
38+
definition <- definition %||% function(...) NextMethod()
39+
registerS3method(generic, signature, definition, envir = frame)
40+
41+
# On cleanup, restore old method or remove the one we added
3542
if (is.null(old)) {
36-
cli::cli_abort(
37-
"Can't find existing S3 method {.code {generic}.{signature}()}."
43+
withr::defer(remove_s3_method(generic, signature, envir = frame), frame)
44+
} else {
45+
withr::defer(
46+
registerS3method(generic, signature, old, envir = frame),
47+
frame
3848
)
3949
}
40-
registerS3method(generic, signature, definition, envir = frame)
41-
withr::defer(registerS3method(generic, signature, old, envir = frame), frame)
50+
51+
invisible()
52+
}
53+
54+
remove_s3_method <- function(generic, class, envir) {
55+
# Extracted from registerS3method()
56+
group_generics <- c("Math", "Ops", "matrixOps", "Summary", "Complex")
57+
if (generic %in% group_generics) {
58+
s3_envir <- .BaseNamespaceEnv
59+
} else {
60+
genfun <- get(generic, envir = envir)
61+
s3_envir <- environment(genfun) %||% .BaseNamespaceEnv
62+
}
63+
64+
if (env_has(s3_envir, ".__S3MethodsTable__.")) {
65+
table <- env_get(s3_envir, ".__S3MethodsTable__.")
66+
env_unbind(table, paste0(generic, ".", class))
67+
}
4268
}
4369

4470
#' @rdname local_mocked_s3_method
@@ -51,20 +77,28 @@ local_mocked_s4_method <- function(
5177
) {
5278
check_string(generic)
5379
check_character(signature)
54-
check_function(definition)
80+
check_function(definition, allow_null = TRUE)
5581

56-
old <- methods::getMethod(generic, signature, optional = TRUE)
57-
if (is.null(old)) {
58-
name <- paste0(generic, "(", paste0(signature, collapse = ","), ")")
59-
cli::cli_abort(
60-
"Can't find existing S4 method {.code {name}}."
61-
)
82+
generic_def <- methods::getGeneric(generic)
83+
if (is.null(generic_def)) {
84+
cli::cli_abort("Can't find generic {.fn {generic}}.")
6285
}
63-
methods::setMethod(generic, signature, definition, where = topenv(frame))
64-
withr::defer(
65-
methods::setMethod(generic, signature, old, where = topenv(frame)),
66-
frame
67-
)
86+
87+
set_method <- function(generic, signature, def) {
88+
env <- topenv(frame)
89+
old <- methods::getMethod(generic, signature, optional = TRUE)
90+
if (is.null(def)) {
91+
methods::removeMethod(generic, signature, env)
92+
} else {
93+
suppressMessages(methods::setMethod(generic, signature, def, env))
94+
}
95+
old
96+
}
97+
98+
old <- set_method(generic_def, signature, definition)
99+
withr::defer(set_method(generic_def, signature, old), frame)
100+
101+
invisible()
68102
}
69103

70104

man/local_mocked_s3_method.Rd

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

tests/testthat/_snaps/mock-oo.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
local_mocked_s3_method("mean", "bar", 1)
1515
Condition
1616
Error in `local_mocked_s3_method()`:
17-
! `definition` must be a function, not the number 1.
17+
! `definition` must be a function or `NULL`, not the number 1.
1818
Code
19-
local_mocked_s3_method("mean", "bar", function() { })
19+
local_mocked_s3_method("notAGeneric", "bar", function() { })
2020
Condition
21-
Error in `local_mocked_s3_method()`:
22-
! Can't find existing S3 method `mean.bar()`.
21+
Error in `get()`:
22+
! object 'notAGeneric' not found
2323

2424
---
2525

@@ -37,12 +37,12 @@
3737
local_mocked_s4_method("mean", "bar", 1)
3838
Condition
3939
Error in `local_mocked_s4_method()`:
40-
! `definition` must be a function, not the number 1.
40+
! `definition` must be a function or `NULL`, not the number 1.
4141
Code
42-
local_mocked_s4_method("mean", "bar", function() { })
42+
local_mocked_s4_method("notAGeneric", "bar", function() { })
4343
Condition
4444
Error in `local_mocked_s4_method()`:
45-
! Can't find existing S4 method `mean(bar)`.
45+
! Can't find generic `notAGeneric()`.
4646

4747
---
4848

tests/testthat/test-mock-oo.R

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,36 @@ test_that("validates its inputs", {
1616
local_mocked_s3_method(1)
1717
local_mocked_s3_method("mean", 1)
1818
local_mocked_s3_method("mean", "bar", 1)
19-
local_mocked_s3_method("mean", "bar", function() {})
19+
local_mocked_s3_method("notAGeneric", "bar", function() {})
2020
})
2121
})
2222

23+
test_that("can mock S3 method that doesn't exist yet", {
24+
x <- structure(list(), class = "test_mock_class")
25+
26+
local({
27+
local_mocked_s3_method("length", "test_mock_class", function(x) 42)
28+
expect_length(x, 42)
29+
})
30+
31+
# Method should be removed after scope ends
32+
expect_length(x, 0)
33+
})
34+
35+
test_that("can temporarily remove S3 method with NULL", {
36+
x <- structure(list(), class = "test_mock_class2")
37+
local_mocked_s3_method("length", "test_mock_class2", function(x) 42)
38+
39+
local({
40+
# Now remove it
41+
local_mocked_s3_method("length", "test_mock_class2", NULL)
42+
expect_length(x, 0)
43+
})
44+
45+
# Method should be restored after scope ends
46+
expect_length(x, 42)
47+
})
48+
2349
# S4 --------------------------------------------------------------------------
2450

2551
test_that("can mock S4 methods", {
@@ -33,14 +59,39 @@ test_that("can mock S4 methods", {
3359
expect_equal(mock_age(jim), 32)
3460
})
3561

36-
3762
test_that("validates its inputs", {
3863
expect_snapshot(error = TRUE, {
3964
local_mocked_s4_method(1)
4065
local_mocked_s4_method("mean", 1)
4166
local_mocked_s4_method("mean", "bar", 1)
42-
local_mocked_s4_method("mean", "bar", function() {})
67+
local_mocked_s4_method("notAGeneric", "bar", function() {})
68+
})
69+
})
70+
71+
test_that("can mock S4 method that doesn't exist yet", {
72+
jim <- TestMockPerson(name = "Jim", age = 32)
73+
74+
local({
75+
local_mocked_s4_method("show", "TestMockPerson", function(object) {
76+
cat("Person:", object@name, "\n")
77+
})
78+
expect_output(show(jim), "Person: Jim")
4379
})
80+
81+
expect_null(methods::getMethod("show", "TestMockPerson", optional = TRUE))
82+
})
83+
84+
test_that("can temporarily remove S4 method with NULL", {
85+
jim <- TestMockPerson(name = "Jim", age = 32)
86+
expect_equal(mock_age(jim), 32)
87+
88+
local({
89+
local_mocked_s4_method("mock_age", "TestMockPerson", NULL)
90+
# Method is removed, so this should error
91+
expect_error(mock_age(jim), "unable to find")
92+
})
93+
94+
expect_equal(mock_age(jim), 32)
4495
})
4596

4697
# R6 --------------------------------------------------------------------------

0 commit comments

Comments
 (0)