Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Best practices for writing R package tests using testthat version 3+. Use when writing, organizing, or improving tests for R packages. Covers test structure, expectations, fixtures, snapshots, mocking, and modern testthat 3 patterns including self-sufficient tests, proper cleanup with withr, and snapshot testing.
.claude/skills/posit-dev-testing-r-packages/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 120% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 77% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 108% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 105% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 425% | 0% |
Modern best practices for R package testing using testthat 3+.
Initialize testing with testthat 3rd edition:
rusethis::use_testthat(3)
This creates tests/testthat/ directory, adds testthat to DESCRIPTION Suggests with Config/testthat/edition: 3, and creates tests/testthat.R.
Mirror package structure:
R/foofy.R → tests in tests/testthat/test-foofy.Rusethis::use_r("foofy") and usethis::use_test("foofy") to create paired filesSpecial files:
helper-*.R - Helper functions and custom expectations, sourced before testssetup-*.R - Run during R CMD check only, not during load_all()fixtures/ - Static test data files accessed via test_path()Tests follow a three-level hierarchy: File → Test → Expectation
rtest_that("descriptive behavior", { result <- my_function(input) expect_equal(result, expected_value) })
Test descriptions should read naturally and describe behavior, not implementation.
For behavior-driven development, use describe() and it():
rdescribe("matrix()", { it("can be multiplied by a scalar", { m1 <- matrix(1:4, 2, 2) m2 <- m1 * 2 expect_equal(matrix(1:4 * 2, 2, 2), m2) }) it("can be transposed", { m <- matrix(1:4, 2, 2) expect_equal(t(m), matrix(c(1, 3, 2, 4), 2, 2)) }) })
Key features:
describe() groups related specifications for a componentit() defines individual specifications (like test_that())it() without code creates pending test placeholdersUse describe() to verify you implement the right things, use test_that() to ensure you do things right.
See references/bdd.md for comprehensive BDD patterns, nested specifications, and test-first workflows.
Three scales of testing:
Micro (interactive development):
rdevtools::load_all() expect_equal(foofy(...), expected)
Mezzo (single file):
rtestthat::test_file("tests/testthat/test-foofy.R") # RStudio: Ctrl/Cmd + Shift + T
Macro (full suite):
rdevtools::test() # Ctrl/Cmd + Shift + T devtools::check() # Ctrl/Cmd + Shift + E
rexpect_equal(10, 10 + 1e-7) # Allows numeric tolerance expect_identical(10L, 10L) # Exact match required expect_all_equal(x, expected) # Every element matches (v3.3.0+)
rexpect_error(1 / "a") expect_error(bad_call(), class = "specific_error_class") expect_no_error(valid_call()) expect_warning(deprecated_func()) expect_no_warning(safe_func()) expect_message(informative_func()) expect_no_message(quiet_func())
rexpect_match("Testing is fun!", "Testing") expect_match(text, "pattern", ignore.case = TRUE)
rexpect_length(vector, 10) expect_type(obj, "list") expect_s3_class(model, "lm") expect_s4_class(obj, "MyS4Class") expect_r6_class(obj, "MyR6Class") # v3.3.0+ expect_shape(matrix, c(10, 5)) # v3.3.0+
rexpect_setequal(x, y) # Same elements, any order expect_contains(fruits, "apple") # Subset check (v3.2.0+) expect_in("apple", fruits) # Element in set (v3.2.0+) expect_disjoint(set1, set2) # No overlap (v3.3.0+)
rexpect_true(condition) expect_false(condition) expect_all_true(vector > 0) # All elements TRUE (v3.3.0+) expect_all_false(vector < 0) # All elements FALSE (v3.3.0+)
Each test should contain all setup, execution, and teardown code:
r# Good: self-contained test_that("foofy() works", { data <- data.frame(x = 1:3, y = letters[1:3]) result <- foofy(data) expect_equal(result$x, 1:3) }) # Bad: relies on ambient state dat <- data.frame(x = 1:3, y = letters[1:3]) test_that("foofy() works", { result <- foofy(dat) # Where did 'dat' come from? expect_equal(result$x, 1:3) })
Use withr to manage state changes:
rtest_that("function respects options", { withr::local_options(my_option = "test_value") withr::local_envvar(MY_VAR = "test") withr::local_package("jsonlite") result <- my_function() expect_equal(result$setting, "test_value") # Automatic cleanup after test })
Common withr functions:
local_options() - Temporarily set optionslocal_envvar() - Temporarily set environment variableslocal_tempfile() - Create temp file with automatic cleanuplocal_tempdir() - Create temp directory with automatic cleanuplocal_package() - Temporarily attach packageWrite tests assuming they will fail and need debugging:
Repeat setup code in tests rather than factoring it out. Test clarity is more important than avoiding duplication.
devtools::load_all() WorkflowDuring development:
devtools::load_all() instead of library()library() calls in testsFor complex output that's difficult to verify programmatically, use snapshot tests. See references/snapshots.md for complete guide.
Basic pattern:
rtest_that("error message is helpful", { expect_snapshot( error = TRUE, validate_input(NULL) ) })
Snapshots stored in tests/testthat/_snaps/.
Workflow:
rdevtools::test() # Creates new snapshots testthat::snapshot_review('name') # Review changes testthat::snapshot_accept('name') # Accept changes
Three approaches for test data:
1. Constructor functions - Create data on-demand:
rnew_sample_data <- function(n = 10) { data.frame(id = seq_len(n), value = rnorm(n)) }
2. Local functions with cleanup - Handle side effects:
rlocal_temp_csv <- function(data, env = parent.frame()) { path <- withr::local_tempfile(fileext = ".csv", .local_envir = env) write.csv(data, path, row.names = FALSE) path }
3. Static fixture files - Store in fixtures/ directory:
rdata <- readRDS(test_path("fixtures", "sample_data.rds"))
See references/fixtures.md for detailed fixture patterns.
Replace external dependencies during testing using local_mocked_bindings(). See references/mocking.md for comprehensive mocking strategies.
Basic pattern:
rtest_that("function works with mocked dependency", { local_mocked_bindings( external_api = function(...) list(status = "success", data = "mocked") ) result <- my_function_that_calls_api() expect_equal(result$status, "success") })
rtest_that("validation catches errors", { expect_error( validate_input("wrong_type"), class = "vctrs_error_cast" ) })
rtest_that("file processing works", { temp_file <- withr::local_tempfile( lines = c("line1", "line2", "line3") ) result <- process_file(temp_file) expect_equal(length(result), 3) })
rtest_that("output respects width", { withr::local_options(width = 40) output <- capture_output(print(my_object)) expect_lte(max(nchar(strsplit(output, "\n")[[1]])), 40) })
rtest_that("str_trunc() handles all directions", { trunc <- function(direction) { str_trunc("This string is moderately long", direction, width = 20) } expect_equal(trunc("right"), "This string is mo...") expect_equal(trunc("left"), "...erately long") expect_equal(trunc("center"), "This stri...ely long") })
r# In tests/testthat/helper-expectations.R expect_valid_user <- function(user) { expect_type(user, "list") expect_named(user, c("id", "name", "email")) expect_type(user$id, "integer") expect_match(user$email, "@") } # In test file test_that("user creation works", { user <- create_user("test@example.com") expect_valid_user(user) })
Always write to temp directory:
r# Good output <- withr::local_tempfile(fileext = ".csv") write.csv(data, output) # Bad - writes to package directory write.csv(data, "output.csv")
Access test fixtures with test_path():
r# Good - works in all contexts data <- readRDS(test_path("fixtures", "data.rds")) # Bad - relative paths break data <- readRDS("fixtures/data.rds")
For advanced testing scenarios, see:
When working with testthat 3 code, prefer modern patterns:
Deprecated → Modern:
context() → Remove (duplicates filename)expect_equivalent() → expect_equal(ignore_attr = TRUE)with_mock() → local_mocked_bindings()is_null(), is_true(), is_false() → expect_null(), expect_true(), expect_false()New in testthat 3:
Config/testthat/edition: 3)waldo::compare() for better diff outputlocal_mocked_bindings() works with byte-compiled codeInitialize: usethis::use_testthat(3)
Run tests: devtools::test() or Ctrl/Cmd + Shift + T
Create test file: usethis::use_test("name")
Review snapshots: testthat::snapshot_review()
Accept snapshots: testthat::snapshot_accept()
Find slow tests: devtools::test(reporter = "slow")
Shuffle tests: devtools::test(shuffle = TRUE)
Other measured skills in the registry, with their headline benchmark lift.