Skip to content

Commit 6122a11

Browse files
committed
Snapshotting updates
1 parent 98b3f47 commit 6122a11

File tree

1 file changed

+104
-53
lines changed

1 file changed

+104
-53
lines changed

vignettes/snapshotting.Rmd

Lines changed: 104 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ Some challenges include:
2323

2424
- Text output that includes many characters like quotes and newlines that require special handling in a string.
2525

26-
- Output that is large, making it painful to define the reference output, and bloating the size of the test file and making it hard to navigate.
26+
- Output that is large, making it painful to define the reference output and bloating the size of the test file.
2727

28-
- Binary formats like plots or images, which are very difficult to describe in code: i.e. the plot looks right, the error message is useful to a human, the print method uses colour effectively.
28+
- Binary formats like plots or images, which are very difficult to describe in code: e.g. the plot looks right, the error message is actionable, or the print method uses colour effectively.
2929

3030
For these situations, testthat provides an alternative mechanism: snapshot tests.
3131
Instead of using code to describe expected output, snapshot tests (also known as [golden tests](https://ro-che.info/articles/2017-12-04-golden-tests)) record results in a separate human readable file.
@@ -103,8 +103,9 @@ test_that("bullets", {
103103
})
104104
```
105105

106-
```{r, include = FALSE}
107-
# Reset snapshot test
106+
```{r}
107+
#| include: false
108+
# finalise snapshot to in order to get an error
108109
snapper$end_file()
109110
snapper$start_file("snapshotting.Rmd", "test")
110111
```
@@ -161,38 +162,14 @@ Within a test, each snapshot expectation is indented by four spaces, i.e. as cod
161162
Because the snapshot output uses the name of the current test file and the current test, snapshot expectations don't really work when run interactively at the console.
162163
Since they can't automatically find the reference output, they instead just print the current value for manual inspection.
163164

164-
## Other types of output
165-
166-
### Messages and warnings
165+
## Testing errors
167166

168167
So far we've focussed on snapshot tests for output printed to the console.
169168
But `expect_snapshot()` also captures messages, errors, and warnings[^1].
170-
The following function generates a some output, a message, and a warning:
171-
172-
[^1]: We no longer recommend `expect_snapshot_output()`, `expect_snapshot_warning()`, or `expect_snapshot_error()`.
173-
Just use `expect_snapshot()`.
169+
Messages and warnings are straightforward, but capturing errors is *slightly* more difficult because `expect_snapshot()` will fail if there's an error:
174170

175171
```{r}
176-
f <- function() {
177-
print("Hello")
178-
message("Hi!")
179-
warning("How are you?")
180-
}
181-
```
182-
183-
And `expect_snapshot()` captures them all:
184-
185-
```{r}
186-
test_that("f() makes lots of noise", {
187-
expect_snapshot(f())
188-
})
189-
```
190-
191-
### Errors
192-
193-
Capturing errors is *slightly* more difficult because `expect_snapshot()` will fail when there's an error:
194-
195-
```{r, error = TRUE}
172+
#| error: true
196173
test_that("you can't add a number and a letter", {
197174
expect_snapshot(1 + "a")
198175
})
@@ -214,37 +191,116 @@ test_that("you can't add weird things", {
214191
expect_snapshot(error = TRUE, {
215192
1 + "a"
216193
mtcars + iris
217-
mean + sum
194+
Sys.Date() + factor()
195+
})
196+
})
197+
```
198+
199+
Just be careful: when you set `error = TRUE`, `expect_snapshot()` checks that at least one expression throws an error, not that every expression throws an error. For example, look above and notice that adding a date and factor generated a warning, not an error.
200+
201+
Snapshot tests are particularly important when testing complex error messages, such as those that you might generate with cli. Here's a more realistic example illustrating how you might test `check_unnamed()`, a function that ensures all arguments in `...` are unnnamed.
202+
203+
```{r}
204+
check_unnamed <- function(..., call = parent.frame()) {
205+
names <- ...names()
206+
has_name <- names != ""
207+
if (!any(has_name)) {
208+
return(invisible())
209+
}
210+
211+
named <- names[has_name]
212+
cli::cli_abort(
213+
c(
214+
"All elements of {.arg ...} must be unnamed.",
215+
i = "You supplied argument{?s} {.arg {named}}."
216+
),
217+
call = call
218+
)
219+
}
220+
221+
test_that("no errors if all arguments unnamed", {
222+
expect_no_error(check_unnamed())
223+
expect_no_error(check_unnamed(1, 2, 3))
224+
})
225+
226+
test_that("actionable feedback if some or all arguments named", {
227+
expect_snapshot(error = TRUE, {
228+
check_unnamed(x = 1, 2)
229+
check_unnamed(x = 1, y = 2)
218230
})
219231
})
220232
```
221233

234+
## Other challenges
235+
236+
### Varying outputs
237+
238+
Sometimes part of the output varies in ways that you can't easily control. In many cases, it's convenient to use mocking (`vignette("mocking")`) to ensure that every run of the function always produces the same output. In other cases, it's easier to manipulate the text output with a regular expression or similar. That's the job of the `transform` argument which should be passed a function that takes a character vector of lines, and returns a modified vector.
222239

223-
Snapshot tests are particularly important when testing complex error messages.
240+
This type of problem often crops up when you are testing a function that gives feedback about a path. In your tests, you'll typically use a temporary path (e.g. from `withr::local_tempfile()`) so if you display the path in a snapshot, it will be different every time. For example, consider this "safe" version of `writeLines()` that requires to explicitly opt-in to overwriting an existing file:
224241

225242
```{r}
226-
divide_positive <- function(x, y) {
227-
if (y <= 0) {
228-
stop("Divisor must be positive, got: ", y)
243+
safe_write_lines <- function(lines, path, overwrite = FALSE) {
244+
if (file.exists(path) && !overwrite) {
245+
cli::cli_abort(c(
246+
"{.path {path}} already exists.",
247+
i = "Set {.code overwrite = TRUE} to overwrite"
248+
))
229249
}
230-
x / y
250+
251+
writeLines(lines, path)
231252
}
253+
```
232254

233-
test_that("divide_positive gives helpful error", {
234-
expect_snapshot_error(divide_positive(10, -2))
235-
expect_snapshot_error(divide_positive(10, 0))
255+
If you use a snapshot test to confirm that the error message is useful, the snapshot will be different every time the test is run:
256+
257+
```{r}
258+
#| include: false
259+
snapper$end_file()
260+
snapper$start_file("snapshotting.Rmd", "safe-write-lines")
261+
```
262+
263+
```{r}
264+
test_that("generates actionable error message", {
265+
path <- withr::local_tempfile(lines = "")
266+
expect_snapshot(safe_write_lines(letters, path), error = TRUE)
236267
})
237268
```
238269

239-
### Human facing outputs
270+
```{r}
271+
#| include: false
272+
snapper$end_file()
273+
snapper$start_file("snapshotting.Rmd", "safe-write-lines")
274+
```
240275

241-
When generating sophisticated error messages that use cli's interpolation and formatting features, snapshot tests are essential for ensuring the messages render correctly with proper styling and content.
276+
```{r}
277+
#| error: true
278+
test_that("generates actionable error message", {
279+
path <- withr::local_tempfile(lines = "")
280+
expect_snapshot(safe_write_lines(letters, path), error = TRUE)
281+
})
282+
```
283+
284+
```{r}
285+
#| include: false
286+
snapper$end_file()
287+
snapper$start_file("snapshotting.Rmd", "test-2")
288+
```
242289

243-
If you're not familiar with `expect_snapshot()` already, start by reading `vignette("snapshotting")`.
290+
One way to fix this problem is to use the `transform` argument to replace the temporary path with a fixed value:
244291

245-
TODO: insert complex `cli::cli_abort()` example.
292+
```{r}
293+
test_that("generates actionable error message", {
294+
path <- withr::local_tempfile(lines = "")
295+
expect_snapshot(
296+
safe_write_lines(letters, path),
297+
error = TRUE,
298+
transform = \(lines) gsub(path, "<path>", lines, fixed = TRUE)
299+
)
300+
})
301+
```
246302

247-
The same idea applies to messages and warnings.
303+
Now even though the path varies, the snapshot does not.
248304

249305
### `local_reproducible_output()`
250306

@@ -254,7 +310,7 @@ By default, testthat sets a number of options that simplify and standardise outp
254310
* Crayon/cli ANSI colouring and hyperlinks are suppressed.
255311
* Unicode characters are suppressed.
256312

257-
These are sound defaults that we have found useful to minimise spurious diffs between tests run in different environment. But it's sometimes necessary to override them in order to test various output features. So, if necessary, you can override these settings by calling `local_reproducible_output()`.
313+
These are sound defaults that we have found useful to minimise spurious difference between tests run in different environments. However, there are times when you want to deliberately test different widths, or ANSI escapes, or unicode characters, so you can override the defaults with `local_reproducible_output()`.
258314

259315
### Snapshotting graphics
260316

@@ -273,12 +329,6 @@ test_that("can snapshot a simple list", {
273329
})
274330
```
275331

276-
## Varying outputs
277-
278-
Sometimes part of the output varies in ways that you can't easily control.
279-
280-
There are two techniques you can use: mocking or the `transform` output.
281-
282332
## Whole file snapshotting
283333

284334
`expect_snapshot()`, `expect_snapshot_output()`, `expect_snapshot_error()`, and `expect_snapshot_value()` use one snapshot file per test file.
@@ -313,10 +363,11 @@ The display varies based on the file type (currently text files, common image fi
313363

314364
Sometimes the failure occurs in a non-interactive environment where you can't run `snapshot_review()`, e.g. in `R CMD check`.
315365
In this case, the easiest fix is to retrieve the `.new` file, copy it into the appropriate directory, then run `snapshot_review()` locally.
316-
If your code was run on a CI platform, you'll need to start by downloading the run "artifact", which contains the check folder.
366+
If this happens on GitHub, testthat provides some tools to help you in the form of `gh_download_artifact()`.
317367

318368
In most cases, we don't expect you to use `expect_snapshot_file()` directly.
319369
Instead, you'll use it via a wrapper that does its best to gracefully skip tests when differences in platform or package versions make it unlikely to generate perfectly reproducible output.
370+
That wrapper should also typically call `announce_snapshot_file()` to avoid snapshots being incorrectly cleaned up; see the documentation for more details.
320371

321372
## Previous work
322373

0 commit comments

Comments
 (0)