Testing

Test your code

Last Time

  1. Helpers automate package chores.
  2. Packages can store data.
  3. Roxygen comments make help pages.
  4. Load, document, check.

Key Ideas for Today

  1. Changes break code you already checked.
  2. A unit test writes down what you expect.
  3. Test typical inputs, edge cases, and errors.
  4. Turn every bug into a test.

Agenda

  1. Why test?
  2. testthat
  3. Choosing test cases
  4. Test-driven development
  5. Development cycle
  6. Testing in data analysis

Why Test?

A grade calculator

Steps

  1. Letter: 90+ is A, 80+ is B, 70+ is C, 60+ is D, otherwise F.

Check: Does 95 get an A? Does 42 get an F?

A grade calculator

Steps

  1. Letter: 90+ is A, 80+ is B, 70+ is C, 60+ is D, otherwise F.
  2. Add “+” if the last digit is 7 to 9, and “−” if it’s 0 to 2.

Check: Does 98 get an A+? Does 91 get an A−?

A grade calculator

Steps

  1. Round to the nearest whole number.
  2. Letter: 90+ is A, 80+ is B, 70+ is C, 60+ is D, otherwise F.
  3. Add “+” if the last digit is 7 to 9, and “−” if it’s 0 to 2.

Check: Does 86.5 get a B+?

A grade calculator

Steps

  1. Round to the nearest whole number.
  2. If the score is 1 or less, it’s a proportion: multiply by 100.
  3. Letter: 90+ is A, 80+ is B, 70+ is C, 60+ is D, otherwise F.
  4. Add “+” if the last digit is 7 to 9, and “−” if it’s 0 to 2.

Check: Does 0.91 get an A−?

Did we keep our promises?

Task: With a partner, run the final steps on these scores.

Score 100 58 42 0.85
Should get A+ F F B
Gets ? ? ? ?
01:00

Bugs come back

  • Every check in the activity passed, at the moment you ran it.
  • Later changes broke behavior you had already checked.
  • Checking by eye happens once. A test runs every time you change the code.

Regression

A bug where a change breaks something that used to work.

Unit test

Code that runs a function on a known input and checks its output against the output you expect.

Tests as a specification

  • Each test is a promise the function makes: this input gives this output.
  • Writing the promise forces a decision. What should a score of 1 get?
  • @examples show users how to call a function; tests check that it still does what it promised.

testthat

Setting up: use_testthat() and use_test()

use_testthat()
use_test("strsplit1")
  • use_testthat() adds testthat to Suggests in DESCRIPTION and creates tests/.
  • use_test() creates a test file named for the code file it tests.
mypkg/
├── DESCRIPTION
├── R/strsplit1.R
└── tests/
    ├── testthat.R
    └── testthat/test-strsplit1.R

Anatomy of a test

tests/testthat/test-strsplit1.R
test_that("strsplit1() splits a string on commas", {
  expect_equal(strsplit1("a,b,c", split = ","),
               c("a", "b", "c"))
})
  • test_that() takes a description and a block of code.
  • The description finishes the sentence “Test that …”.
  • Inside are one or more expectations: each one checks a single thing.

Expectations

Expectation Passes when
expect_equal(x, y) x equals y
expect_type(x, type) x has type type
expect_length(x, n) x has length n
expect_true(x) x is TRUE
expect_error(code, msg) code errors with msg
expect_warning(code, msg) code warns with msg

Your Turn

Task: Write a test that strsplit1("a,b,c", split = ",") gives a character vector (not a list) with three elements.

02:00

Running tests with test()

devtools::test()
✔ |          3 | strsplit1

══ Results ═════════════════════════════════════════════════════════════════════
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 3 ]
  • Calls load_all(), then runs every file in tests/testthat/.
  • Counts expectations, not tests: two tests, three expectations.
  • check() runs the tests too.

Reading a failure

Add a test that splits on a period, then run test():

tests/testthat/test-strsplit1.R
test_that("strsplit1() splits on a period", {
  expect_equal(strsplit1("3.14", split = "."), c("3", "14"))
})
Failure ('test-strsplit1.R:12:3'): strsplit1() splits on a period
Expected `strsplit1("3.14", split = ".")` to equal `c("3", "14")`.
`actual`:   ""  ""   "" ""
`expected`: "3" "14"
[ FAIL 1 | WARN 0 | SKIP 0 | PASS 3 ]

Choosing Test Cases

Typical inputs

  • The inputs you had in mind when you wrote the function.
  • For strsplit1(): "a,b,c".
  • Easy to write, and they catch the obvious breaks.
  • But bugs live where you weren’t thinking.

Edge case

An input at the boundary of what a function handles: e.g. empty, a single value, very large, missing, or containing unusual characters.

For strsplit1:

  • an empty string
  • a delimiter at the start or end
  • two delimiters in a row
  • more than one string.

Your Turn

Question: Predict what each call returns. Which results are bugs?

strsplit1("", split = ",")
strsplit1(",a", split = ",")
strsplit1("a,b,", split = ",")
strsplit1(c("a,b", "c,d"), split = ",")
02:00

Errors and warnings

Some inputs should fail. Test that they fail, and for the right reason.

test_that("log() rejects text", {
  expect_error(log("a"), "non-numeric")
})

test_that("as.numeric() warns when it can't convert", {
  expect_warning(as.numeric("a"), "NAs introduced")
})
  • The second argument is matched against the message.
  • Without it, any error passes, including a typo in your own code.

Test-Driven Development

Red, green, refactor

  1. Red: write a test for the behavior you want. Run it; it fails.
  2. Green: write the simplest code that makes it pass.
  3. Refactor: revise the code. The tests stay green.

Turning a bug report into a test

strsplit1(c("a,b", "c,d"), ",") returns "a" "b". Where did "c,d" go?

Red: before fixing anything, write the test.

tests/testthat/test-strsplit1.R
test_that("strsplit1() errors on more than one string", {
  expect_error(strsplit1(c("a,b", "c,d"), split = ","),
               "single string")
})

Make it pass

Green: stop() throws an error with your message.

R/strsplit1.R
strsplit1 <- function(x, split) {
  if (length(x) != 1) {
    stop("`x` must be a single string, not ",
         length(x), " strings.")
  }
  strsplit(x, split = split, fixed = TRUE)[[1]]
}
  • The check fixes the bug report; fixed = TRUE fixes the period test.
  • The test stays in the package, so this bug can’t come back silently.

Guess the Metaphor

A test suite is like a fortified dam: each test is a stone set where a leak once broke through. Change the code all you like; the old bugs stay behind the wall.

Development Cycle

Load, document, test, check

Command Does Shortcut
load_all() loads code and data Shift + L
document() rebuilds help pages Shift + D
test() runs the tests Shift + T
check() runs CRAN’s checks Shift + E

Shortcuts are Ctrl/Cmd + Shift + a letter in Positron/RStudio.

Testing in Data Analysis

Expectations in a cleaning script

zev_raw <- read_excel("data/raw/New_ZEV_Sales_Last_updated_07-17-2026_ada.xlsx",
                      sheet = "County")
zev <- zev_raw |>
  rename(year = `Data Year`, quarter = Quarter, county = COUNTY,
         fuel_type = FUEL_TYPE, make = MAKE, model = MODEL,
         n_vehicles = `Number of Vehicles`) |>
  mutate(in_state = county != "Out Of State")

# Nothing dropped; expectations still hold
stopifnot(
  nrow(zev) == nrow(zev_raw),
  all(zev$quarter %in% 1:4),
  all(zev$n_vehicles >= 1)
)

When the data are updated next quarter, a broken expectation fails loudly.

Testing an LLM

One prompt sent to an LLM five times gives five different answers; each is checked against expectations, three pass and two fail, for a pass rate of 3 out of 5.

Key Ideas for Today

  1. Changes break code you already checked.
  2. A unit test writes down what you expect.
  3. Test typical inputs, edge cases, and errors.
  4. Turn every bug into a test.

For Next Time

  1. PS 6 released today, due 11/14.