Testing with testthat

Part 2

Outline

  1. Updating snapshots
  2. Writing clean tests with withr
  3. Test coverage with covr

Updating Snapshots

Where we left off

In “Function Design Best Practices” we added input validation with better error messages.

Our snapshot tests captured the old error output - they will now fail.

test()

What a snapshot failure looks like

── Failure (test-cpue.R:14:3): cpue error message is informative ──────────────
Snapshot of `cpue("not a number", 10)` has changed:
old[3] == "  non-numeric argument to binary operator"
new[3] == "  catch must be numeric, not character"

The test is doing its job: it detected that your output changed.

Now you decide: is this the output you intended?

Reviewing snapshot changes

snapshot_review()   # interactive diff viewer

If the new output is correct:

snapshot_accept()   # accept all pending changes

The snapshot workflow

  1. Change something (a function, an error message, a print method)
  2. Run test() - snapshot tests fail
  3. Run snapshot_review() - inspect the diff
  4. Accept if the change is intentional, fix the code if it is not
  5. Commit the updated .md files in tests/testthat/_snaps/

This cycle will repeat throughout the course. That is normal and expected.

Writing Clean Tests

Tests and global state

A test that changes global state can affect tests that run after it, AND it can affect the user’s session if they run tests interactively.

Global state includes:

  • Options (getOption())
  • Environment variables
  • Working directory
  • Random seed
  • Files on disk

The fix: make every change local to the test that needs it.

withr

The withr package provides helpers to make temporary changes that are automatically reversed.

use_package("withr", type = "Suggests")

Aside: Why do we set type = "Suggests" for withr?

Two families of withr_ helpers:

Prefix Scope Use for
local_* Runs until the current environment exits Inside test_that()
with_* Runs for one expression Wrapping a single call

examples:

getOption("digits")
#> [1] 7
pi
#> [1] 3.141593

withr::with_options(
  list(digits = 2),
  print(pi)
)
#> [1] 3.1

getOption("digits")
#> [1] 7
Sys.time()
#> [1] "2026-02-23 12:24:34 PST"

withr::with_timezone(
  "Europe/Paris", 
  print(Sys.time())
)
#> [1] "2026-02-23 21:25:36 CET"

withr::with_timezone(
  "America/New_York", 
  print(Sys.time())
)
#> [1] "2026-02-23 15:26:13 EST"

Sys.time()
#> [1] "2026-02-23 12:26:39 PST"

Recall: verbose via a package option

cpue() reads verbosity from an option:

cpue <- function(
  catch,
  effort,
  gear_factor = 1,
  method = c("ratio", "log"),
  verbose = getOption("fishr.verbose", FALSE)
) { ... }

Tests that set options(fishr.verbose = TRUE) must restore the original value afterward - or they will leak into other tests.

Testing options with local_options

test_that("cpue uses verbosity when option set to TRUE", {
  withr::local_options(fishr.verbose = TRUE) # reset when this block exits

  expect_snapshot(cpue(100, 10))
})

test_that("cpue is not verbose when option set to FALSE", {
  withr::local_options(fishr.verbose = FALSE) # reset when this block exits

  expect_silent(cpue(100, 10))
})

with_options for a single assertion

Provide options in a list

test_that("cpue verbosity falls back to FALSE when not set", {
  withr::with_options(
    list(fishr.verbose = NULL), # reset as soon as this expression finishes
    expect_no_message(cpue(100, 10))
  )
})

Your Turn

Exercise

Add the same verbose argument to biomass_index() and write clean tests for it.

  1. Update R/biomass.R to read from getOption("fishr.verbose", FALSE)
  2. Open the test file: use_test("biomass")
  3. Write tests using withr::local_options()
  4. Run test() and accept any new snapshots

How would you test that the verbose message is correct?

Solution

test_that("biomass_index uses verbosity when set as an option", {
  withr::local_options(fishr.verbose = TRUE)

  expect_snapshot(biomass_index(cpue = 5, area_swept = 100))
})

test_that("biomass_index verbosity falls back to FALSE when not set", {
  withr::local_options(fishr.verbose = NULL)

  expect_no_message(biomass_index(cpue = 5, area_swept = 100))
})

More withr Helpers

Common helpers

Helper Resets
local_options() / with_options() R options
local_envvar() / with_envvar() Environment variables
local_dir() / with_dir() Working directory
local_tempfile() / with_tempfile() Temp file (deleted after)
local_tempdir() / with_tempdir() Temp directory (deleted after)
local_seed() / with_seed() Random seed

Example: testing file output

test_that("results can be written to a file", {
  outfile <- withr::local_tempfile(fileext = ".csv")

  write_cpue_results(cpue(100, 10), file = outfile)

  expect_true(file.exists(outfile))
  expect_equal(nrow(read.csv(outfile)), 1)
})
# outfile is deleted automatically after the test

Quiz

Where have we already altered the global state in our test helpers?

tests/testthat/helper.R calls set.seed() directly - this sets the seed for the entire session.

# Before
generate_fishing_data <- function(n = 10) {
  set.seed(67)
  data.frame(...)
}
# After
generate_fishing_data <- function(n = 10) {
  withr::local_seed(67)
  data.frame(...)
}