Skip to content

Commit a012eef

Browse files
authored
Improve custom expectation docs (#2143)
With more examples and details about helper functions. Fixes #2113. Fixes #2132. Fixes #2072.
1 parent dc341fc commit a012eef

File tree

7 files changed

+190
-64
lines changed

7 files changed

+190
-64
lines changed

NEWS.md

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

3+
* `vignette("custom-expectations)` has been overhauled to make it much clearer how to create high-quality expectations (#2113, #2132, #2072).
34
* `expect_snapshot()` and friends will now fail when creating a new snapshot on CI. This is usually a signal that you've forgotten to run it locally before committing (#1461).
45
* `expect_snapshot_value()` can now handle expressions that generate `-` (#1678) or zero length atomic vectors (#2042).
56
* `expect_matches()` failures should be a little easier to read (#2135).

R/expect-that.R

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,37 @@
22
#'
33
#' @description
44
#' These are the primitives that you can use to implement your own expectations.
5-
#' Every branch of code inside an expectation must call either `pass()` or
6-
#' `fail()`; learn more in `vignette("custom-expectation")`.
5+
#' Regardless of how it's called an expectation should either return `pass()`,
6+
#' `fail()`, or throw an error (if for example, the arguments are invalid).
77
#'
8-
#' @param message a string to display.
8+
#' Learn more about creating your own expectations in
9+
#' `vignette("custom-expectation")`.
10+
#'
11+
#' @param message Failure message to send to the user. It's best practice to
12+
#' describe both what is expected and what was actually received.
913
#' @param info Character vector continuing additional information. Included
1014
#' for backward compatibility only and new expectations should not use it.
1115
#' @param srcref Location of the failure. Should only needed to be explicitly
1216
#' supplied when you need to forward a srcref captured elsewhere.
17+
#' @param trace_env If `trace` is not specified, this is used to generate an
18+
#' informative traceack for failures. You should only need to set this if
19+
#' you're calling `fail()` from a helper function; see
20+
#' `vignette("custom-expectation")` for details.
1321
#' @param trace An optional backtrace created by [rlang::trace_back()].
1422
#' When supplied, the expectation is displayed with the backtrace.
15-
#' @param trace_env If `is.null(trace)`, this is used to automatically
16-
#' generate a traceback running from `test_code()`/`test_file()` to
17-
#' `trace_env`. You'll generally only need to set this if you're wrapping
18-
#' an expectation inside another function.
23+
#' Expert use only.
1924
#' @export
2025
#' @examples
21-
#' \dontrun{
22-
#' test_that("this test fails", fail())
23-
#' test_that("this test succeeds", succeed())
26+
#' expect_length <- function(object, n) {
27+
#' act <- quasi_label(rlang::enquo(object), arg = "object")
28+
#'
29+
#' act_n <- length(act$val)
30+
#' if (act_n != n) {
31+
#' msg <- sprintf("%s has length %i, not length %i.", act$lab, act_n, n)
32+
#' return(fail(msg))
33+
#' }
34+
#'
35+
#' pass(act$val)
2436
#' }
2537
fail <- function(
2638
message = "Failure has been forced",
@@ -53,7 +65,7 @@ pass <- function(value) {
5365
#' Mark a test as successful
5466
#'
5567
#' This is an older version of [pass()] that exists for backwards compatibility.
56-
#' You should now use `pass()` instead`
68+
#' You should now use `pass()` instead.
5769
#'
5870
#' @export
5971
#' @inheritParams fail

man/expect.Rd

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

man/expectation.Rd

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/fail.Rd

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

man/succeed.Rd

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

vignettes/custom-expectation.Rmd

Lines changed: 132 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,59 +11,99 @@ vignette: >
1111
library(testthat)
1212
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
1313
14-
# Pretend we're snapsotting
14+
# Pretend we're snapshotting
1515
snapper <- local_snapshotter(fail_on_new = FALSE)
1616
snapper$start_file("snapshotting.Rmd", "test")
1717
```
1818

19-
This vignette shows you how to write your expectations that work identically to the built-in `expect_` functions.
20-
21-
You can use these either locally by putting them in a helper file, or export them from your package.
19+
This vignette shows you how to write your own expectations. You can use them within your package by putting them in a helper file, or share them with others by exporting them from your package.
2220

2321
## Expectation basics
2422

25-
There are three main parts to writing an expectation, as illustrated by `expect_length()`:
23+
An expectation has three main parts, as illustrated by `expect_length()`:
2624

2725
```{r}
2826
expect_length <- function(object, n) {
2927
# 1. Capture object and label
3028
act <- quasi_label(rlang::enquo(object))
3129
32-
# 2. Verify the expectations
30+
# 2. Check if expectations are violated
3331
act_n <- length(act$val)
3432
if (act_n != n) {
3533
msg <- sprintf("%s has length %i, not length %i.", act$lab, act_n, n)
3634
return(fail(msg))
3735
}
3836
39-
# 3. Pass
37+
# 3. Pass when expectations are met
4038
pass(act$val)
4139
}
4240
```
4341

44-
### Capture value and label
42+
The first step in any expectation is to use `quasi_label()` to capture a "labelled value", i.e. a list that contains both the value (`$val`) for testing and a label (`$lab`) for messaging. This is a pattern that exists for fairly esoteric reasons; you don't need to understand it, just copy and paste it 🙂.
4543

46-
The first step in any expectation is to capture the actual object, and generate a label for it to use if a failure occur. All testthat expectations support quasiquotation so that you can unquote variables. This makes it easier to generate good labels when the expectation is called from a function or within a for loop.
44+
Next you need to check each way that `object` could violate the expectation. In this case, there's only one check, but in more complicated cases there can be multiple checks. In most cases, it's easier to check for violations one by one, using early returns to `fail()`. This makes it easier to write informative failure messages that state both what the object is and what you expected.
4745

48-
By convention, the first argument to every `expect_` function is called `object`, and you capture its value (`val`) and label (`lab`) with `act <- quasi_label(enquo(object))`, where `act` is short for actual (in constrast to expected).
46+
Also note that you need to use `return(fail())` here. You won't see the problem when interactively testing your function because when run outside of `test_that()`, `fail()` throws an error, causing the function to terminate early. When running inside of `test_that()`, however, `fail()` does not stop execution because we want to collect all failures in a given test.
4947

50-
### Verify the expectation
48+
Finally, if the object is as expected, call `pass()` with `act$val`. Returning the input value is good practice since expectation functions are called primarily for their side-effects (triggering a failure). This allows expectations to be chained:
5149

52-
Now we can check if our expectation is met and return `fail()` if not. The most challenging job here is typically generating the error message because you want it to be as self-contained as possible. This means it should typically give both the expected and actual value, along with the name of the object passed to the expectation. testthat expectations use `sprintf()`, but if you're familiar with {glue}, you might want to use that instead.
50+
```{r}
51+
mtcars |>
52+
expect_type("list") |>
53+
expect_s3_class("data.frame") |>
54+
expect_length(11)
55+
```
56+
57+
### Testing your expectations
5358

54-
More complicated expectations will have more `if` statements. For example, we might want to make our `expect_length()` function include an assertion that `object` is a vector:
59+
Once you've written your expectation, you need to test it, and luckily testthat comes with three expectations designed specifically to test expectations:
60+
61+
* `expect_success()` checks that your expectation emits exactly one success and zero failures.
62+
* `expect_failure()` checks that your expectation emits exactly one failure and zero successes.
63+
* `expect_failure_snapshot()` captures the failure message in a snapshot, making it easier to review if it's useful or not.
64+
65+
The first two expectations are particularly important because they ensure that your expectation reports the correct number of successes and failures to the user.
66+
67+
```{r}
68+
test_that("expect_length works as expected", {
69+
x <- 1:10
70+
expect_success(expect_length(x, 10))
71+
expect_failure(expect_length(x, 11))
72+
})
73+
74+
test_that("expect_length gives useful feedback", {
75+
x <- 1:10
76+
expect_snapshot_failure(expect_length(x, 11))
77+
})
78+
```
79+
80+
## Examples
81+
82+
The following sections show you a few more variations, loosely based on existing testthat expectations.
83+
84+
### `expect_vector_length()`
85+
86+
Let's make `expect_length()` a bit more strict by also checking that the input is a vector. R is a bit weird in that it gives a length to pretty much every object, and you can imagine not wanting this code to succeed:
87+
88+
```{r}
89+
expect_length(mean, 1)
90+
```
91+
92+
To do this we'll add an extra check that the input is either an atomic vector or a list:
5593

5694
```{r}
5795
expect_vector_length <- function(object, n) {
58-
act <- quasi_label(rlang::enquo(object), arg = "object")
96+
act <- quasi_label(rlang::enquo(object))
5997
60-
if (!is.atomic(act$val) || !is.list(act$val)) {
98+
# It's non-trivial to check if an object is a vector in base R so we
99+
# use an rlang helper
100+
if (!rlang::is_vector(act$val)) {
61101
msg <- sprintf("%s is a %s, not a vector", act$lab, typeof(act$val))
62102
return(fail(msg))
63103
}
64104
65105
act_n <- length(act$val)
66-
if (act$n != n) {
106+
if (act_n != n) {
67107
msg <- sprintf("%s has length %i, not length %i.", act$lab, act_n, n)
68108
return(fail(msg))
69109
}
@@ -72,33 +112,91 @@ expect_vector_length <- function(object, n) {
72112
}
73113
```
74114

75-
Note that it's really important to `return(fail())` here. You wont see the problem when interactively testing your function because when run outside of `test_that()`, `fail()` throws an error, causing the function to terminate early. When running inside of `test_that()` however, `fail()` does not stop execution because we want to collect all failures in a given test.
115+
```{r}
116+
#| error: true
117+
expect_vector_length(mean, 1)
118+
expect_vector_length(mtcars, 15)
119+
```
76120

77-
### Pass the test
121+
### `expect_s3_class()`
78122

79-
If no assertions fail, call `pass()` with the input value (usually `act$val`). Returning the input value is good practice since expectation functions are called primarily for their side-effects (triggering a failure). This allows expectations to be chained:
123+
Or imagine if you're checking to see if an object inherits from an S3 class. In R, there's no direct way to tell if an object is an S3 object: you can confirm that it's an object, then that it's not an S4 object. So you might organize your expectation this way:
80124

81125
```{r}
82-
mtcars |>
83-
expect_type("list") |>
84-
expect_s3_class("data.frame") |>
85-
expect_length(11)
126+
expect_s3_class <- function(object, class) {
127+
if (!rlang::is_string(class)) {
128+
rlang::abort("`class` must be a string.")
129+
}
130+
131+
act <- quasi_label(rlang::enquo(object))
132+
133+
if (!is.object(act$val)) {
134+
return(fail(sprintf("%s is not an object.", act$lab)))
135+
}
136+
137+
if (isS4(act$val)) {
138+
return(fail(sprintf("%s is an S4 object, not an S3 object.", act$lab)))
139+
}
140+
141+
if (!inherits(act$val, class)) {
142+
msg <- sprintf(
143+
"%s inherits from %s not %s.",
144+
act$lab,
145+
paste0(class(object), collapse = "/"),
146+
paste0(class, collapse = "/")
147+
)
148+
return(fail(msg))
149+
}
150+
151+
pass(act$val)
152+
}
86153
```
87154

88-
## Testing your expectations
155+
```{r}
156+
#| error: true
157+
x1 <- 1:10
158+
TestClass <- methods::setClass("Test", contains = "integer")
159+
x2 <- TestClass()
160+
x3 <- factor()
161+
162+
expect_s3_class(x1, "integer")
163+
expect_s3_class(x2, "integer")
164+
expect_s3_class(x3, "integer")
165+
expect_s3_class(x3, "factor")
166+
```
89167

90-
testthat comes with three expectations designed specifically to test expectations: `expect_success()` and `expect_failure()`:
168+
Note that I also check that the `class` argument must be a string. This is an error, not a failure, because it suggests you're using the function incorrectly.
91169

92-
* `expect_success()` checks that your expectation emits exactly one success and zero failures.
93-
* `expect_failure()` checks that your expectation emits exactly one failure and zero successes.
94-
* `expect_failure_snapshot()` captures the failure message in a snapshot, making it easier to review if it's useful or not.
170+
```{r}
171+
#| error: true
172+
expect_s3_class(x1, 1)
173+
```
174+
175+
## Repeated code
176+
177+
As you write more expectations, you might discover repeated code that you want to extract out into a helper. Unfortunately, creating helper functions is not straightforward in testthat because every `fail()` captures the calling environment in order to give maximally useful tracebacks. Because getting this right is not critical (you'll just get a slightly suboptimal traceback in the case of failure), we don't recommend bothering. However, we document it here because it's important to get it right in testthat itself.
178+
179+
The key challenge is that `fail()` captures a `trace_env` which should be the execution environment of the expectation. This usually works, because the default value of `trace_env` is `caller_env()`. But when you introduce a helper, you'll need to explicitly pass it along:
95180

96181
```{r}
97-
test_that("expect_length works as expected", {
98-
x <- 1:10
99-
expect_success(expect_length(x, 10))
100-
expect_failure(expect_length(x, 11))
182+
expect_length_ <- function(act, n, trace_env = caller_env()) {
183+
act_n <- length(act$val)
184+
if (act_n != n) {
185+
msg <- sprintf("%s has length %i, not length %i.", act$lab, act_n, n)
186+
return(fail(msg, trace_env = trace_env))
187+
}
101188
102-
expect_snapshot_failure(expect_length(x, 11))
103-
})
189+
pass(act$val)
190+
}
191+
192+
expect_length <- function(object, n) {
193+
act <- quasi_label(rlang::enquo(object))
194+
expect_length_(act, n)
195+
}
104196
```
197+
198+
A few recommendations:
199+
200+
* The helper shouldn't be user facing, so we give it a `_` suffix to make that clear.
201+
* It's typically easiest for a helper to take the labelled value produced by `quasi_label()`.
202+
* Your helper should usually call both `fail()` and `pass()` and be returned from the wrapping expectation.

0 commit comments

Comments
 (0)