Skip to content

Commit ec50771

Browse files
committed
Mocking braindump
1 parent 5b773d5 commit ec50771

File tree

1 file changed

+172
-29
lines changed

1 file changed

+172
-29
lines changed

vignettes/mocking.Rmd

Lines changed: 172 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,55 +7,198 @@ vignette: >
77
%\VignetteEncoding{UTF-8}
88
---
99

10-
```{r, include = FALSE}
11-
knitr::opts_chunk$set(
12-
collapse = TRUE,
13-
comment = "#>"
14-
)
10+
```{r}
11+
#| include: false
12+
library(testthat)
13+
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
14+
15+
# Pretend we're snapshotting
16+
snapper <- local_snapshotter(fail_on_new = FALSE)
17+
snapper$start_file("snapshotting.Rmd", "test")
18+
19+
# Pretend we're testing testthat so we can use mocking
20+
Sys.setenv(TESTTHAT_PKG = "testthat")
1521
```
1622

17-
```{r setup}
18-
library(testthat)
23+
Mocking allows you to temporarily replace the implementation of a function something that makes it easier to test. It's useful when testing failure scenarios that are hard to generate organically (e.g. what happens if dependency X isn't installed?), making tests more reliable by eliminating potential variability, making tests faster, and generally to make it possible to test functions that would otherwise be very challenging or impossible to test.
24+
25+
testthat implements mocking primarily with `local_mocked_bindings()` for mocking functions, and we'll focus on that function in this vignette. But testthat provides other methods for specialised cases: you can use `local_mocked_s3_method()` to mock an S3 method, `local_mocked_s4_method()` to mock a S4 method, and `local_mocked_r6_class()` to mock an R6 class. Once you understand the basic idea of mocking, I think it should be straightforward to apply these other functions to your problem.
26+
27+
## Getting started with mocking
28+
29+
Imagine you're writing a function like `rlang::check_installed()` that gives a nice error message if a package is required but not found. A simple base R implementation might look something like this:
30+
31+
```{r}
32+
check_installed <- function(pkg, min_version = NULL) {
33+
if (!requireNamespace(pkg, quietly = TRUE)) {
34+
stop(sprintf("{%s} is not installed.", pkg))
35+
}
36+
if (!is.null(min_version)) {
37+
pkg_version <- packageVersion(pkg)
38+
if (pkg_version < min_version) {
39+
stop(sprintf(
40+
"{%s} version %s is installed, but %s is required.",
41+
pkg,
42+
pkg_version,
43+
min_version
44+
))
45+
}
46+
}
47+
48+
invisible()
49+
}
50+
```
51+
52+
When thinking about testing this function there are three cases that you want to test:
53+
54+
* `pkg` is not installed.
55+
* `pkg` is installed but doesn't meet the specified minimum version.
56+
* `pkg` is installed and does meet the minimum version.
57+
58+
You could write a test with fake data:
59+
60+
```{r}
61+
test_that("check_installed() requires package to be installed", {
62+
expect_no_error(check_installed("testthat"))
63+
expect_snapshot(check_installed("doesntexist"), error = TRUE)
64+
})
65+
```
66+
67+
This is probably fine but it feels a little fragile (i.e. it'll break in the unlikely event someone creates a package called `doesntexist`). Additionally, it's hard to test that the error messages are informative:
68+
69+
```{r}
70+
test_that("check_installed() requires minimum version", {
71+
expect_no_error(check_installed("testthat"))
72+
expect_no_error(check_installed("testthat", "1.0.0"))
73+
expect_snapshot(check_installed("testthat", "99.99.999"), error = TRUE)
74+
})
75+
```
76+
77+
Because the error message includes the current package version. (You'll also have to hope I don't release a lot of new testthat versions 🤣.)
78+
79+
We can make these tests more robust with mocking. First we need to add `requireNamspace` and `packageVersion` bindings in our package (this doesn't break existing function usage because of <https://adv-r.hadley.nz/functions.html#functions-versus-variables>):
80+
81+
```{r}
82+
requireNamespace <- NULL
83+
packageVersion <- NULL
84+
```
85+
86+
Now we can write some tests:
87+
88+
```{r}
89+
test_that("check_installed() requires package to be installed", {
90+
local_mocked_bindings(requireNamespace = function(...) TRUE)
91+
expect_no_error(check_installed("package-name"))
92+
93+
local_mocked_bindings(requireNamespace = function(...) FALSE)
94+
expect_snapshot(check_installed("package-name"), error = TRUE)
95+
})
96+
97+
test_that("check_installed() requires minimum version", {
98+
local_mocked_bindings(
99+
requireNamespace = function(...) TRUE,
100+
packageVersion = function(...) numeric_version("3.0.0")
101+
)
102+
103+
expect_no_error(check_installed("package-name"))
104+
expect_no_error(check_installed("package-name", "1.0.0"))
105+
expect_snapshot(check_installed("package-name", "4.0.1"), error = TRUE)
106+
})
19107
```
20108

21-
Mocking is a useful technique when all else fails: for the duration of your test, you make some function return whatever you need it to.
109+
## Case studies
22110

23-
Note that mocking works best on your own functions and functions that you import. But it's possible to mock base R functions and functions that you don't import. See `?local_mocked_bindings` for more details.
111+
### Pretending we're on a different platform
112+
113+
```{r}
114+
#| include: false
115+
system_os <- NULL
116+
```
24117

25-
<!-- https://github.com/search?q=%28org%3Ar-lib+OR+org%3Atidyverse%29+local_mocked+bindings+path%3Atests%2Ftestthat&type=code -->
118+
`testthat::skip_on_os()` allows to skip tests on different platforms. But how do we test this reliably, not knowing what platform the test suite is running on? We use mocking to pretend that we're always on windows:
26119

27-
* Package versions and installed status
28-
* User interaction (e.g. `readline()`)
29-
* Retrieving external state (vcr typically best) but sometimes better at higher level. e.g. token prices in ellemr.
30-
* Pretending that you're on a different operating system
31-
* Cause things to deliberately error
32-
* The passing of time
33-
* Slow functions that aren't important for specific test
34-
* 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.
35-
* Record internal state with `<<-`.
120+
```{r}
121+
#| eval: false
122+
test_that("can skip on multiple oses", {
123+
local_mocked_bindings(system_os = function() "windows")
36124
37-
### Mocking variations
125+
expect_skip(skip_on_os("windows"))
126+
expect_skip(skip_on_os(c("windows", "linux")))
127+
expect_no_skip(skip_on_os("linux"))
128+
})
129+
```
38130

131+
### Speeding up tests
39132

40-
## Examples
133+
<https://github.com/r-lib/usethis/blob/main/tests/testthat/test-release.R>
41134

42-
### Interactivity and user input
135+
Context: `usethis::use_release_issue()` creates a GitHub issue with a bulleted list of actions that we recommend following when releasing your package. But some of the bullets depend on complex conditions that might either vary or take a while to compute. So blocks like this:
43136

44137
```{r}
45138
#| eval: false
46-
local_mocked_bindings(interactive = function() FALSE)
139+
local_mocked_bindings(
140+
get_revdeps = function() character(),
141+
gh_milestone_number = function(...) NA
142+
)
47143
```
48144

49-
But we generally recommend using `rlang::is_interactive()`. Can be manually overridden by `rlang_interactive` option, whih is automatically set inside of tests.
145+
Assume that there are no revdeps for the package, which is both slow to compute and if we use a real package might vary over time, and assume there's no related GitHub milestone, which is also slow to compute and outside of our direct control.
50146

51147
### Managing time
52148

149+
<https://github.com/r-lib/httr2/blob/main/tests/testthat/test-req-throttle.R>
150+
151+
Context: `httr2::req_throttle()` prevents multiple requests from being made too quickly, using a tool called a leaky token bucket. This tool is inextricably tied to real time because you want to allow more requests as time elapses. So how do you test this? I started by using `Sys.sleep()` but this either made my tests both slow (because I'd sleep for a second or two) and unreliable (because sometime more time elapsed than I expected). Eventually I figured out that I could "manually control" time by using a mocked function that returns the value of a variable I control. This allows me to manual advance time and carefully test the implications.
152+
153+
You might see the basic idea with this simpler example. Imagine I have a function factory that I can use to record how much time has elapsed since I first called the function.
154+
53155
```{r}
54-
#| eval: false
55156
unix_time <- function() unclass(Sys.time())
56157
57-
time <- 0
58-
local_mocked_bindings(unix_time = function(time) time)
59-
time <- 1
60-
time <- 10
158+
elapsed <- function() {
159+
start <- unix_time()
160+
function() {
161+
unix_time() - start
162+
}
163+
}
164+
165+
timer <- elapsed()
166+
Sys.sleep(0.5)
167+
timer()
61168
```
169+
170+
Hopefully you can see how hard this will be too test! But I can "manipulate time" by mocking `unix_time()` and creating a reliable test:
171+
172+
```{r}
173+
test_that("elapsed() meausres elapsed time", {
174+
time <- 1
175+
local_mocked_bindings(unix_time = function() time)
176+
177+
timer <- elapsed()
178+
expect_equal(timer(), 0)
179+
180+
time <- 2
181+
expect_equal(timer(), 1)
182+
})
183+
```
184+
185+
186+
## How does mocking work?
187+
188+
Before we go futher, it's worth discussing how mocking works. It took us some iteration (`testthat::with_mock()`, as well as {mockery}, {mockr}, and {mockthat} packages) to get to current state and understanding how it works will help you to understand some of the tradeoffs. The underlying principle of `local_mocked_bindings()` is that mocking should never touch code that you don't "own", or in other words mocking should only affect the operation of your code, not code in other packages.
189+
190+
It's fairly straightforward to understand how this might work for functions in your package: `local_mocked_bindings()` just temporarily replaces your implementation with a different implementation. But what happens when you want to mock a function from another package? It would be unhygenic to reach into another package and change its code.
191+
192+
This brings us to the first important limiation of testthat's implementation of mocking: it doesn't work with `::`. If you need to mock a function called in this way you have two options:
193+
194+
1. Switch from `pkg::fun()` to `fun()` by importing `fun` into your `NAMESPACE` (e.g. with `@importFrom pkg fun`).
195+
196+
2. Write a wrapper around the function and mock that:
197+
198+
```{r}
199+
pkg_fun <- function(...) {
200+
pkg::fun(...)
201+
}
202+
```
203+
204+
In our experience one of these two options generally feels natural. (And when it doesn't, it still feels like the right tradeoff to me.)

0 commit comments

Comments
 (0)