Testing with testthat

Part 1

Outline

  1. Test infrastructure
  2. Writing tests
  3. Test helpers and fixtures
  4. Testing messages and warnings
  5. Snapshot testing introduction
  6. Running your tests

Test Infrastructure Setup

Start a branch

pr_init("add-tests")

Good habit: each logical unit of work gets its own branch and PR.

Initialize testthat

use_testthat()

This creates:

  • tests/testthat/ - directory for test files
  • tests/testthat.R - script to run all tests
  • Adds testthat to Suggests in DESCRIPTION

Create a test file

Tests for R/{name}.R go in tests/testthat/test-{name}.R

use_test("cpue")

Creates tests/testthat/test-cpue.R linked to R/cpue.R

Writing Tests

Basic structure

test_that("cpue calculates simple ratio correctly", {
  expect_equal(cpue(catch = 100, effort = 10), 10)
  expect_equal(cpue(catch = 50, effort = 2), 25)
})
  • One test_that() block = one behaviour
  • Description should read like a sentence
  • Multiple expect_*() calls per block are fine

Common expectations

  • expect_equal(): exact equality (with a tolerance for numeric)
  • expect_identical(): exact equality
  • expect_lt(), expect_gt(), etc.: comparisons
  • expect_length(): checks length of output
  • expect_type(): checks the type of the output
  • expect_true(), expect_false(): checks if condition is TRUE/FALSE
  • expect_message(): checks if code produces a message
  • more…

See testthat documentation

test_that("cpue handles vectors of data", {
  catches <- c(100, 200, 300)
  efforts <- c(10, 10, 10)
  expected_results <- c(10, 20, 30)

  expect_equal(cpue(catches, efforts), expected_results)
})

Use specific expectations

Vague

expect_true(
  is.numeric(cpue(100, 10))
)

Specific

expect_type(
  cpue(100, 10),
  "double"
)

Specific expectations give better failure messages - you see what failed, not just that it failed.

Testing optional (default) arguments

test_that("gear_factor standardization scales correctly", {
  expect_equal(cpue(catch = 100, effort = 10, gear_factor = 0.5), 5)

  # Default gear_factor = 1 should have no effect
  expect_equal(
    cpue(catch = 100, effort = 10),
    cpue(catch = 100, effort = 10, gear_factor = 1)
  )
})

Testing edge cases

test_that("cpue handles missing data", {
  expect_true(is.na(cpue(NA_real_, 10)))
  expect_true(is.na(cpue(100, NA_real_)))
})

Edge cases often reveal assumptions baked into your code.

Run your tests

test()

Commit your initial tests, then push to open the PR:

pr_push()

Test Helpers and Fixtures

The problem

Tests that share setup code can become repetitive.

What if the setup logic is complex or used in many tests?

Solution: helper files and setup files

Create a helper file

use_testthat_helper()

Creates tests/testthat/helper.R - loaded before every test run.

Does not contain test code itself, but defines functions that can be used in tests.

# tests/testthat/helper.R

generate_fishing_data <- function(n = 10) {
  set.seed(67)
  data.frame(
    catch = runif(n, 10, 500),
    effort = runif(n, 1, 20),
    gear_factor = runif(n, 1, 5)
  )
}

Using helpers in tests

test_that("cpue works with generated data", {
  data <- generate_fishing_data(n = 5)

  result <- cpue(data$catch, data$effort)

  expect_equal(
    result,
    c(34.053, 9.065, 19.239, 135.640, 6.372),
    tolerance = 1e-3
  )
})

Setup files

setup.R runs once before all tests - good for expensive fixtures.

# tests/testthat/setup.R

reference_data <- data.frame(
  catch = c(100, 200, 300),
  effort = c(10, 10, 10),
  expected_cpue = c(10, 20, 30)
)


test_that("cpue matches reference data", {
  result <- cpue(reference_data$catch, reference_data$effort)
  expect_equal(result, reference_data$expected_cpue)
})

Testing Messages and Warnings

Add a verbose parameter

Update R/cpue.R:

#' @param verbose Logical; if TRUE, prints processing info (default FALSE)
cpue <- function(catch, effort, gear_factor = 1, verbose = FALSE) {
  if (verbose) {
    message("Processing ", length(catch), " records")
  }
  raw_cpue <- catch / effort
  raw_cpue * gear_factor
}

Testing with expect_message

test_that("cpue provides informative message when verbose", {
  expect_message(
    cpue(c(100, 200), c(10, 20), verbose = TRUE),
    "Processing 2 records"
  )
})

test_that("cpue is silent by default", {
  expect_no_message(cpue(100, 10))
})

Snapshot Testing

Overview

Snapshot testing captures output and compares against a saved reference.

  • Creates and saves the reference snapshot on first run
  • Subsequent runs compare output to the snapshot and fail if it changes
  • testthat provides tools to compare and accept/reject changes

Best for:

  • Complex text output (print methods, formatted messages)
  • Errors and warnings - prefer expect_snapshot() over expect_error()/expect_warning() because it captures the full output for review
  • Plots (with the vdiffr package)

Basic snapshot test

expect_snapshot(
  my_function("input that produces complex output"),
)


test_that("cpue errors when input is not numeric", {
  expect_snapshot(
    cpue("five", 10),
    error = TRUE
  )
})

First run throws a warning and creates tests/testthat/_snaps/cpue.md - review it!

First run

test()

testthat writes the snapshot to tests/testthat/_snaps/:

# cpue errors when input is not numeric

    Code
      cpue("five", 10)
    Error
      non-numeric argument to binary operator

Commit the snapshot file - it becomes your reference.

Snapshot testing for warnings

  • Save yourself from having to copy and paste the warning text into our test code.
  • Easy to review and update when warning text changes.
test_that("cpue warns when catch and effort lengths differ", {
  expect_snapshot(
    cpue(c(100, 200, 300), c(10, 20))
  )

  expect_no_warning(cpue(100, 10))
})

When snapshots change

After modifying output, tests fail. Review and accept:

snapshot_review() # interactive diff viewer
snapshot_accept() # accept all changes

We’ll dive deeper into snapshot workflows in Part 2.

Make a commit

Commit your snapshot tests - including the _snaps/ files testthat created.

Running Your Tests

Three levels

What How Shortcut
Single test Ctrl+Enter on test_that() block -
Active file devtools::test_active_file() Ctrl/Cmd+T
Whole package devtools::test() Shift+Ctrl/Cmd+T

Full check

test() # run tests only

check() # full package check (includes tests)

Your Turn

Exercise

Add tests for biomass_index(). Aim for at least one snapshot test.

# With R/biomass.R open, create the test file:
use_test()
# or
use_test("biomass")

Things to test:

  • Correct calculation for simple inputs
  • Vector inputs
  • Error on invalid input (use expect_snapshot(error = TRUE))

Our solutions

test_that("biomass_index calculates correctly", {
  expect_equal(biomass_index(cpue = 10, area_swept = 5), 50)
  expect_equal(biomass_index(cpue = 20, area_swept = 2.5), 50)
})

test_that("biomass_index handles vectors", {
  expect_equal(
    biomass_index(c(10, 20, 30), c(5, 5, 5)),
    c(50, 100, 150)
  )
})

test_that("biomass_index throws error on invalid input", {
  expect_snapshot(biomass_index("ten", 5), error = TRUE)
  expect_snapshot(biomass_index(10, "five"), error = TRUE)
})

Make a commit

Commit your biomass_index() tests and any new snapshot files.

Merge PR and clean up

Once all tests pass and check() is clean:

check()

Push any remaining commits, then merge the PR on GitHub:

pr_push()

Clean up your branch:

pr_finish()