Skip to content

Commit 358a340

Browse files
authored
set_state_inspector() to compare global state before and after each test (#1816)
Fixes #1674
1 parent 03d741b commit 358a340

File tree

11 files changed

+181
-3
lines changed

11 files changed

+181
-3
lines changed

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export(quasi_label)
168168
export(run_cpp_tests)
169169
export(set_max_fails)
170170
export(set_reporter)
171+
export(set_state_inspector)
171172
export(setup)
172173
export(show_failure)
173174
export(shows_message)

NEWS.md

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

3+
* `set_state_inspector()` allows to to register a function that's called
4+
before and after every test, reporting on any differences. This
5+
is very useful for detecting if any of your tests have made changes to
6+
global state (like options, env vars, or connections) (#1674). This
7+
function was inspired by renv's testing infrastructure.
8+
9+
* Only report test files that take longer than a second (#1806).
10+
311
* New `expect_contains()` and `expect_in()` that works similarly to
412
`expect_true(all(expected %in% object))` or
513
`expect_true(all(object %in% expected))` but give more informative failure
@@ -29,7 +37,6 @@
2937
* testthat no longer truncates tracebacks and uses rlang's default tree
3038
display.
3139

32-
3340
# testthat 3.1.8
3441

3542
* `expect_snapshot()` differences no longer use quotes.

R/test-state.R

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#' State inspected
2+
#'
3+
#' @description
4+
#' One of the most pernicious challenges to debug is when a test runs fine
5+
#' in your test suite, but fails when you run it interactively (or similarly,
6+
#' it fails randomly when running your tests in parallel). One of the most
7+
#' common causes of this problem is accidentally changing global state in a
8+
#' previous test (e.g. changing an option, an environment variable, or the
9+
#' working directory). This is hard to debug, because it's very hard to figure
10+
#' out which test made the change.
11+
#'
12+
#' Luckily testthat provides a tool to figure out if tests are changing global
13+
#' state. You can register a state inspector with `set_state_inspector()` and
14+
#' testthat will run it before and after each test, store the results, then
15+
#' report if there are any differences. For example, if you wanted to see if
16+
#' any of your tests were changing options or environment variables, you could
17+
#' put this code in `tests/testthat/helper-state.R`:
18+
#'
19+
#' ```R
20+
#' set_state_inspector(function() {
21+
#' list(
22+
#' options = options(),
23+
#' envvars = Sys.getenv()
24+
#' )
25+
#' })
26+
#' ```
27+
#'
28+
#' (You might discover other packages outside your control are changing
29+
#' the global state, in which case you might want to modify this function
30+
#' to ignore those values.)
31+
#'
32+
#' Other problems that can be troublesome to resolve are CRAN check notes that
33+
#' report things like connections being left open. You can easily debug
34+
#' that problem with:
35+
#'
36+
#' ```R
37+
#' set_state_inspector(function() {
38+
#' getAllConnections()
39+
#' }
40+
#' ```
41+
#'
42+
#' @export
43+
#' @param callback Either a zero-argument function that returns an object
44+
#' capturing global state that you're interested in, or `NULL`.
45+
set_state_inspector <- function(callback) {
46+
47+
if (!is.null(callback) && !(is.function(callback) && length(formals(callback)) == 0)) {
48+
cli::cli_abort("{.arg callback} must be a zero-arg function, or NULL")
49+
}
50+
51+
the$state_inspector <- callback
52+
invisible()
53+
}
54+
55+
testthat_state_condition <- function(before, after, call) {
56+
57+
diffs <- waldo_compare(before, after, x_arg = "before", y_arg = "after")
58+
59+
if (length(diffs) == 0) {
60+
return(NULL)
61+
}
62+
63+
srcref <- attr(call, "srcref")
64+
warning_cnd(
65+
message = c("Global state has changed:", set_names(format(diffs), "")),
66+
srcref = srcref
67+
)
68+
}
69+
70+
inspect_state <- function() {
71+
if (is.null(the$state_inspector)) {
72+
NULL
73+
} else {
74+
the$state_inspector()
75+
}
76+
}

R/test-that.R

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ test_code <- function(test, code, env, default_reporter, skip_on_empty = TRUE) {
192192

193193
withr::local_options(testthat_topenv = test_env)
194194

195+
before <- inspect_state()
195196
tryCatch(
196197
withCallingHandlers(
197198
{
@@ -211,7 +212,14 @@ test_code <- function(test, code, env, default_reporter, skip_on_empty = TRUE) {
211212
# skip silently terminate code
212213
skip = function(e) {}
213214
)
215+
after <- inspect_state()
214216

217+
if (!is.null(test)) {
218+
cnd <- testthat_state_condition(before, after, call = sys.call(-1))
219+
if (!is.null(cnd)) {
220+
register_expectation(cnd, 0)
221+
}
222+
}
215223

216224
invisible(ok)
217225
}

_pkgdown.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ reference:
5252
- is_testing
5353
- skip
5454
- teardown_env
55+
- set_state_inspector
5556

5657
- title: Run tests
5758
contents:

man/set_state_inspector.Rd

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# set_state_inspector() verifies its inputs
2+
3+
Code
4+
set_state_inspector(function(x) 123)
5+
Condition
6+
Error in `set_state_inspector()`:
7+
! `callback` must be a zero-arg function, or NULL
8+
9+
# can detect state changes
10+
11+
[ FAIL 0 | WARN 1 | SKIP 0 | PASS 1 ]
12+
13+
== Warnings ====================================================================
14+
-- Warning ('reporters/state-change.R:1:1'): options ---------------------------
15+
Global state has changed:
16+
`before$x` is NULL
17+
`after$x` is a double vector (1)
18+
19+
[ FAIL 0 | WARN 1 | SKIP 0 | PASS 1 ]
20+
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
test_that("options", {
2+
options(x = 1)
3+
expect_true(TRUE)
4+
})

tests/testthat/test-teardown.R

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
test_that("teardown adds to queue", {
22
local_edition(2)
3-
on.exit(teardown_reset())
3+
withr::defer({teardown_reset()})
44

55
expect_length(file_teardown_env$queue, 0)
66

@@ -13,7 +13,7 @@ test_that("teardown adds to queue", {
1313

1414
test_that("teardowns runs in order", {
1515
local_edition(2)
16-
on.exit(teardown_reset())
16+
withr::defer(teardown_reset())
1717

1818
a <- 1
1919
teardown(a <<- 2)

tests/testthat/test-test-state.R

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
test_that("set_state_inspector() verifies its inputs", {
2+
expect_snapshot(set_state_inspector(function(x) 123), error = TRUE)
3+
})
4+
5+
test_that("can detect state changes", {
6+
local_options(x = NULL)
7+
set_state_inspector(function() list(x = getOption("x")))
8+
withr::defer(set_state_inspector(NULL))
9+
10+
expect_snapshot_reporter(CheckReporter$new(), test_path("reporters/state-change.R"))
11+
})

0 commit comments

Comments
 (0)