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.
Modern best practices for R package testing using testthat 3+.
Initialize testing with testthat 3rd edition:
usethis::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
test_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():
describe("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):
devtools::load_all()
expect_equal(foofy(...), expected)
Mezzo (single file):
testthat::test_file("tests/testthat/test-foofy.R")
# RStudio: Ctrl/Cmd + Shift + T
Macro (full suite):
devtools::test() # Ctrl/Cmd + Shift + T
devtools::check() # Ctrl/Cmd + Shift + E
expect_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+)
expect_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())
expect_match("Testing is fun!", "Testing")
expect_match(text, "pattern", ignore.case = TRUE)
expect_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+
expect_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+)
expect_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:
# 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:
test_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:
test_that("error message is helpful", {
expect_snapshot(
error = TRUE,
validate_input(NULL)
)
})
Snapshots stored in tests/testthat/_snaps/.
Workflow:
devtools::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:
new_sample_data <- function(n = 10) {
data.frame(id = seq_len(n), value = rnorm(n))
}
2. Local functions with cleanup - Handle side effects:
local_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:
data <- 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:
test_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")
})
test_that("validation catches errors", {
expect_error(
validate_input("wrong_type"),
class = "vctrs_error_cast"
)
})
test_that("file processing works", {
temp_file <- withr::local_tempfile(
lines = c("line1", "line2", "line3")
)
result <- process_file(temp_file)
expect_equal(length(result), 3)
})
test_that("output respects width", {
withr::local_options(width = 40)
output <- capture_output(print(my_object))
expect_lte(max(nchar(strsplit(output, "\n")[[1]])), 40)
})
test_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")
})
# 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:
# 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():
# 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)
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer