Skip to content

Commit 5fcd5da

Browse files
committed
Roughing out ideas
1 parent 2ae77ff commit 5fcd5da

File tree

1 file changed

+75
-205
lines changed

1 file changed

+75
-205
lines changed

vignettes/challenging-tests.Rmd

Lines changed: 75 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
2-
title: "Challenging Testing Problems"
2+
title: "Challenging testing problems"
33
output: rmarkdown::html_vignette
44
vignette: >
5-
%\VignetteIndexEntry{Challenging Testing Problems}
5+
%\VignetteIndexEntry{Challenging testing problems}
66
%\VignetteEngine{knitr::rmarkdown}
77
%\VignetteEncoding{UTF-8}
88
---
@@ -14,31 +14,38 @@ knitr::opts_chunk$set(
1414
)
1515
```
1616

17+
Testing is easy when your functions are pure: they take some inputs and return predictable outputs. But real-world code often involves randomness, external state, graphics, user interaction, and other challenging elements. This vignette provides practical solutions for testing these tricky scenarios.
18+
19+
Other packages:
20+
21+
* For testing graphical output, we recommend vdiffr.
22+
* For testing code that uses HTTP requests we recommend vcr or httptest2.
23+
1724
```{r setup}
1825
library(testthat)
1926
```
2027

21-
Testing is easy when your functions are pure: they take some inputs and return predictable outputs. But real-world code often involves randomness, external state, graphics, user interaction, and other challenging elements. This vignette provides practical solutions for testing these tricky scenarios.
28+
## External state
2229

23-
## Output Affected by RNG
30+
Tests should be isolated from global options, environment variables, and other external state that might affect behavior.
2431

25-
Random number generation can make tests non-deterministic. Use `withr::local_seed()` to ensure reproducible results within your tests.
32+
### Output affected by RNG
2633

27-
### The Problem
34+
Random number generation can make tests non-deterministic. Use `withr::local_seed()` to ensure reproducible results within your tests.
2835

2936
```{r, eval = FALSE}
30-
# This test will randomly pass or fail
31-
test_that("random sample has expected properties", {
32-
x <- sample(1:100, 10)
33-
expect_length(x, 10)
34-
expect_true(all(x %in% 1:100))
35-
# This might fail randomly:
36-
expect_equal(x[1], 42)
37+
simulate_data <- function(n) {
38+
rnorm(n, mean = 0, sd = 1)
39+
}
40+
41+
test_that("simulate_data returns correct structure", {
42+
result <- simulate_data(5)
43+
expect_length(result, 5)
44+
expect_type(result, "double")
45+
expect_equal(result[1], 1.048, tolerance = 0.001)
3746
})
3847
```
3948

40-
### The Solution
41-
4249
```{r}
4350
test_that("random sample has expected properties", {
4451
withr::local_seed(123)
@@ -50,28 +57,7 @@ test_that("random sample has expected properties", {
5057
})
5158
```
5259

53-
For functions that internally use random numbers:
54-
55-
```{r}
56-
simulate_data <- function(n) {
57-
rnorm(n, mean = 0, sd = 1)
58-
}
59-
60-
test_that("simulate_data returns correct structure", {
61-
withr::local_seed(456)
62-
result <- simulate_data(5)
63-
expect_length(result, 5)
64-
expect_type(result, "double")
65-
# Test specific values with fixed seed
66-
expect_equal(result[1], 1.048, tolerance = 0.001)
67-
})
68-
```
69-
70-
## Output Affected by External State
71-
72-
Tests should be isolated from global options, environment variables, and other external state that might affect behavior.
73-
74-
### Global Options
60+
### Global options
7561

7662
```{r}
7763
# Function that depends on global options
@@ -89,7 +75,7 @@ test_that("format_number respects digits option", {
8975
})
9076
```
9177

92-
### Environment Variables
78+
### Environment variables
9379

9480
```{r}
9581
# Function that depends on environment variables
@@ -108,51 +94,31 @@ test_that("get_api_url uses default when env var not set", {
10894
})
10995
```
11096

111-
### Working Directory
97+
### Reading and writing files
11298

11399
```{r}
114100
test_that("function works in different directories", {
115-
withr::local_dir(tempdir())
101+
withr::local_dir(withr::local_tempdir())
116102
# Test code that depends on working directory
117103
writeLines("test content", "temp_file.txt")
118104
expect_true(file.exists("temp_file.txt"))
119105
# File will be cleaned up automatically
120106
})
121107
```
122108

123-
## Graphical Output
124-
125-
Testing plots and other graphical output requires specialized tools. The [vdiffr](https://vdiffr.r-lib.org/) package provides visual regression testing for ggplot2 and base R graphics.
126-
127-
### Setting Up vdiffr
109+
### Local wrappers
128110

129-
```{r, eval = FALSE}
130-
# In your test file
131-
library(vdiffr)
132-
133-
test_that("plot looks correct", {
134-
p <- ggplot(mtcars, aes(wt, mpg)) + geom_point()
135-
expect_doppelganger("basic scatterplot", p)
136-
})
137-
```
111+
If you want to make your own function, you should take a `frame` argument. frame is an environment on the call stack, i.e. it's the execution environment of some function, and the local effects will be undone when that function is completed. Underneath the hood this is all wrappers around `on.exit()`.
138112

139-
### Base R Graphics
113+
```{r}
140114
141-
```{r, eval = FALSE}
142-
test_that("base R plot is correct", {
143-
expect_doppelganger("base histogram", function() {
144-
hist(rnorm(100), main = "Normal Distribution")
145-
})
146-
})
147115
```
148116

149-
The first time you run these tests, vdiffr will create reference images. Subsequent runs will compare against these references and flag any visual differences.
150-
151-
## Errors and User-Facing Text
117+
## Errors and user-facing text
152118

153119
Error messages, warnings, and other user-facing text should be tested to ensure they're helpful and consistent. Snapshots are perfect for this.
154120

155-
### Testing Error Messages
121+
### Testing error messages
156122

157123
```{r}
158124
divide_positive <- function(x, y) {
@@ -168,22 +134,7 @@ test_that("divide_positive gives helpful error", {
168134
})
169135
```
170136

171-
### Testing Warnings
172-
173-
```{r}
174-
maybe_warn <- function(x) {
175-
if (x < 0) {
176-
warning("Negative value detected: ", x)
177-
}
178-
abs(x)
179-
}
180-
181-
test_that("maybe_warn produces expected warning", {
182-
expect_snapshot(maybe_warn(-5))
183-
})
184-
```
185-
186-
### Testing Complex Output
137+
### Testing complex output
187138

188139
```{r}
189140
summarize_data <- function(x) {
@@ -198,59 +149,45 @@ test_that("summarize_data output is correct", {
198149
})
199150
```
200151

201-
## HTTP Responses
152+
The same idea applies to messages and warnings.
202153

203-
Testing code that makes HTTP requests requires mocking to avoid external dependencies. Use httr2 mocking for httr2-based code, or httptest2 for httr-based code.
154+
### `local_reproducible_output()`
204155

205-
### With httr2
206156

207-
```{r, eval = FALSE}
208-
library(httr2)
209157

210-
get_user_info <- function(user_id) {
211-
req <- request("https://api.example.com") |>
212-
req_url_path_append("users", user_id)
213-
resp <- req_perform(req)
214-
resp_body_json(resp)
215-
}
158+
### Transformations
216159

217-
test_that("get_user_info handles successful response", {
218-
# Mock the HTTP response
219-
with_mocked_responses(
220-
request("https://api.example.com/users/123") |>
221-
req_method("GET") |>
222-
mock_response(
223-
status_code = 200,
224-
body = '{"id": 123, "name": "Alice"}'
225-
),
226-
{
227-
result <- get_user_info(123)
228-
expect_equal(result$id, 123)
229-
expect_equal(result$name, "Alice")
230-
}
231-
)
232-
})
233-
```
160+
Sometimes part of the output varies in ways that you can't easily control. There are two techniques you can use: mocking (described next) or the `transform` output.
234161

235-
### With httptest2
162+
## Mocking
236163

237-
```{r, eval = FALSE}
238-
library(httptest2)
164+
<!-- https://github.com/search?q=%28org%3Ar-lib+OR+org%3Atidyverse%29+local_mocked+bindings+path%3Atests%2Ftestthat&type=code -->
239165

240-
test_that("API call works", {
241-
with_mock_api({
242-
# httptest2 will look for mock files in tests/testthat/api.example.com/
243-
result <- get_user_info(123)
244-
expect_equal(result$id, 123)
245-
})
246-
})
247-
```
166+
* Package versions and installed status
167+
* Retrieving external state (vcr typically best) but sometimes better at higher level. e.g. token prices in ellemr.
168+
* Pretending that you're on a different operating system
169+
* Cause things to deliberately error
170+
* The passing of time
171+
* Slow functions that aren't important for specific test
172+
* Sometimes easier or more clear to mock a function rather than setting options/env vars. And generally just tickling some branch that would otherwise be hard to reach.
173+
* Record internal state with `<<-`.
174+
175+
```{r}
176+
unix_time <- function() unclass(Sys.time())
177+
178+
time <- 0
179+
local_mocked_bindings(unix_time = function(time) time)
180+
time <- 1
181+
time <- 10
182+
```
248183
249-
## Interactivity
184+
### Interactivity and user input
250185
251-
Interactive functions that prompt for user input need mocking to work in automated tests.
186+
```{r}
187+
local_mocked_bindings(interactive = function() FALSE)
188+
```
252189

253-
### Mocking User Input
190+
But we generally recommend using `rlang::is_interactive()`. Can be manually overridden by `rlang_interactive` option, whih is automatically set inside of tests.
254191

255192
```{r}
256193
ask_yes_no <- function(question) {
@@ -269,31 +206,10 @@ test_that("ask_yes_no handles no response", {
269206
})
270207
```
271208

272-
### Mocking File Selection
273209

274-
```{r}
275-
read_user_file <- function() {
276-
file_path <- file.choose()
277-
readLines(file_path)
278-
}
210+
## Reducing duplication
279211

280-
test_that("read_user_file works with mocked file selection", {
281-
temp_file <- tempfile()
282-
writeLines(c("line 1", "line 2"), temp_file)
283-
284-
mockery::stub(read_user_file, "file.choose", temp_file)
285-
result <- read_user_file()
286-
287-
expect_equal(result, c("line 1", "line 2"))
288-
unlink(temp_file)
289-
})
290-
```
291-
292-
## Testing Many Combinations
293-
294-
When you need to test many parameter combinations, use helper functions and loops to avoid repetitive code.
295-
296-
### Using Helper Functions
212+
### Using helper functions
297213

298214
```{r}
299215
# Function to test
@@ -311,13 +227,15 @@ test_power <- function(x, n, expected) {
311227
}
312228
313229
# Test many combinations
314-
test_power(2, 3, 8)
315-
test_power(5, 2, 25)
316-
test_power(10, 0, 1)
317-
test_power(-3, 2, 9)
230+
test_that("power combinations work", {
231+
test_power(2, 3, 8)
232+
test_power(5, 2, 25)
233+
test_power(10, 0, 1)
234+
test_power(-3, 2, 9)
235+
})
318236
```
319237

320-
### Using Loops for Systematic Testing
238+
### Using loops
321239

322240
```{r}
323241
test_that("power_function works for multiple bases and exponents", {
@@ -328,60 +246,12 @@ test_that("power_function works for multiple bases and exponents", {
328246
)
329247
330248
for (i in seq_len(nrow(test_cases))) {
331-
expect_equal(
332-
power_function(test_cases$x[i], test_cases$n[i]),
333-
test_cases$expected[i],
334-
info = paste("Failed for x =", test_cases$x[i], "n =", test_cases$n[i])
335-
)
336-
}
337-
})
338-
```
339-
340-
### Property-Based Testing
341-
342-
```{r}
343-
test_that("power_function satisfies mathematical properties", {
344-
# Test that x^0 = 1 for any non-zero x
345-
for (x in c(-10, -1, 1, 2, 10, 100)) {
346-
expect_equal(power_function(x, 0), 1,
347-
info = paste("x^0 should equal 1 for x =", x))
348-
}
349-
350-
# Test that x^1 = x for any x
351-
for (x in c(-5, 0, 1, 7, 100)) {
352-
expect_equal(power_function(x, 1), x,
353-
info = paste("x^1 should equal x for x =", x))
354-
}
355-
})
356-
```
357-
358-
### Testing Edge Cases Systematically
359-
360-
```{r}
361-
test_that("power_function handles edge cases correctly", {
362-
# Test error conditions
363-
error_cases <- list(
364-
list(x = 5, n = -1, pattern = "Negative exponents"),
365-
list(x = 0, n = 0, pattern = "0\\^0 is undefined")
366-
)
367-
368-
for (case in error_cases) {
369-
expect_error(
370-
power_function(case$x, case$n),
371-
case$pattern,
372-
info = paste("Expected error for x =", case$x, "n =", case$n)
373-
)
249+
test_that(paste("x =", test_cases$x[i], "n =", test_cases$n[i]), {
250+
expect_equal(
251+
power_function(test_cases$x[i], test_cases$n[i]),
252+
test_cases$expected[i]
253+
)
254+
})
374255
}
375256
})
376257
```
377-
378-
## Best Practices
379-
380-
1. **Isolate tests**: Use `withr` functions to ensure tests don't affect each other
381-
2. **Make tests deterministic**: Control randomness with seeds
382-
3. **Test the interface**: Focus on testing user-facing behavior, not implementation details
383-
4. **Use appropriate tools**: Choose the right mocking/testing approach for your specific challenge
384-
5. **Document complex setups**: Add comments explaining why specific mocking or setup is needed
385-
6. **Keep tests fast**: Mock external dependencies to avoid network calls and file I/O when possible
386-
387-
By addressing these challenging scenarios systematically, you can build confidence that your code works correctly under all conditions your users might encounter.

0 commit comments

Comments
 (0)