
Testing R Packages
- 565 installs
- 455 repo stars
- Updated August 4, 2026
- posit-dev/skills
testing-r-packages is a Claude skill that generates comprehensive R package test suites using testthat 3+ and usethis conventions for developers maintaining CRAN-ready R libraries.
About
testing-r-packages is a posit-dev/skills agent skill with 471 installs on skills.sh that teaches modern R package testing with testthat 3rd edition. It initializes tests via usethis::use_testthat(3), creating tests/testthat/, Config/testthat/edition: 3 in DESCRIPTION, and tests/testthat.R, while mirroring R/foofy.R with tests/testthat/test-foofy.R. Developers reach for testing-r-packages when scaffolding or expanding test coverage for R packages, pairing usethis::use_r and usethis::use_test files, and following Posit best practices instead of ad hoc stopifnot checks. The repository reports 415 GitHub stars and passes Gen Agent Trust Hub, Socket, and Snyk security audits on skills.sh.
- Generates testthat skeletons for functions and packages
- Supports test coverage analysis and reporting
- Integrates with R CMD check and devtools workflows
- Produces ready-to-run test files and fixtures
- Handles edge cases and parameterized tests
Testing R Packages by the numbers
- 565 all-time installs (skills.sh)
- +18 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #603 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/posit-dev/skills --skill testing-r-packagesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 565 |
|---|---|
| repo stars | ★ 455 |
| Last updated | August 4, 2026 |
| Repository | posit-dev/skills ↗ |
How do you test R packages with testthat?
Automatically generate comprehensive test suites for R packages using testthat and related frameworks.
Who is it for?
R package authors using testthat 3 and usethis who want mirrored test files and modern edition-3 setup guidance from Posit.
Skip if: Python or JavaScript projects, or R scripts that are not structured as installable packages with DESCRIPTION and tests/testthat/.
When should I use this skill?
Scaffolding R package tests, expanding testthat coverage, or initializing testthat edition 3 with usethis for a new package.
What you get
testthat 3 test directories, paired test-*.R files, tests/testthat.R harness, and DESCRIPTION Suggests configuration.
- tests/testthat/test-*.R files
- tests/testthat.R harness
By the numbers
- 471 installs listed on skills.sh
- Repository has 415 GitHub stars
Files
Testing R Packages with testthat
Modern best practices for R package testing using testthat 3+.
Initial Setup
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.
File Organization
Mirror package structure:
- Code in
R/foofy.R→ tests intests/testthat/test-foofy.R - Use
usethis::use_r("foofy")andusethis::use_test("foofy")to create paired files
Special files:
helper-*.R- Helper functions and custom expectations, sourced before testssetup-*.R- Run duringR CMD checkonly, not duringload_all()fixtures/- Static test data files accessed viatest_path()
Test Structure
Tests follow a three-level hierarchy: File → Test → Expectation
Standard Syntax
test_that("descriptive behavior", {
result <- my_function(input)
expect_equal(result, expected_value)
})Test descriptions should read naturally and describe behavior, not implementation.
BDD Syntax (describe/it)
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 (liketest_that())- Supports nesting for hierarchical organization
it()without code creates pending test placeholders
Use `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.
Running Tests
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 + TMacro (full suite):
devtools::test() # Ctrl/Cmd + Shift + T
devtools::check() # Ctrl/Cmd + Shift + ECore Expectations
Equality
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+)Errors, Warnings, Messages
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())Pattern Matching
expect_match("Testing is fun!", "Testing")
expect_match(text, "pattern", ignore.case = TRUE)Structure and Type
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+Sets and Collections
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+)Logical
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+)Design Principles
1. Self-Sufficient Tests
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)
})2. Self-Contained Tests (Cleanup Side Effects)
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 package
3. Plan for Test Failure
Write tests assuming they will fail and need debugging:
- Tests should run independently in fresh R sessions
- Avoid hidden dependencies on earlier tests
- Make test logic explicit and obvious
4. Repetition is Acceptable
Repeat setup code in tests rather than factoring it out. Test clarity is more important than avoiding duplication.
5. Use devtools::load_all() Workflow
During development:
- Use
devtools::load_all()instead oflibrary() - Makes all functions available (including unexported)
- Automatically attaches testthat
- Eliminates need for
library()calls in tests
Snapshot Testing
For 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 changesTest Fixtures and Data
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.
Mocking
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")
})Common Patterns
Testing Errors with Specific Classes
test_that("validation catches errors", {
expect_error(
validate_input("wrong_type"),
class = "vctrs_error_cast"
)
})Testing with Temporary Files
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)
})Testing with Modified Options
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)
})Testing Multiple Related Cases
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")
})Custom Expectations in Helper Files
# 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)
})File System Discipline
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")Advanced Topics
For advanced testing scenarios, see:
- [references/bdd.md](references/bdd.md) - BDD-style testing with describe/it, nested specifications, test-first workflows
- [references/snapshots.md](references/snapshots.md) - Snapshot testing, transforms, variants
- [references/mocking.md](references/mocking.md) - Mocking strategies, webfakes, httptest2
- [references/fixtures.md](references/fixtures.md) - Fixture patterns, database fixtures, helper files
- [references/advanced.md](references/advanced.md) - Skipping tests, secrets management, CRAN requirements, custom expectations, parallel testing
testthat 3 Modernizations
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:
- Edition system (
Config/testthat/edition: 3) - Improved snapshot testing
waldo::compare()for better diff output- Unified condition handling
local_mocked_bindings()works with byte-compiled code- Parallel test execution support
Quick Reference
Initialize: 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)
Advanced Testing Topics
Skipping Tests
Skip tests conditionally when requirements aren't met:
Built-in Skip Functions
test_that("API integration works", {
skip_if_offline()
skip_if_not_installed("httr2")
skip_on_cran()
skip_on_os("windows")
result <- call_external_api()
expect_true(result$success)
})Common skip functions:
skip()- Skip unconditionally with messageskip_if()- Skip if condition is TRUEskip_if_not()- Skip if condition is FALSEskip_if_offline()- Skip if no internetskip_if_not_installed(pkg)- Skip if package unavailableskip_on_cran()- Skip on CRAN checksskip_on_os(os)- Skip on specific OSskip_on_ci()- Skip on continuous integrationskip_unless_r(version)- Skip unless R version requirement met (testthat 3.3.0+)
Custom Skip Conditions
skip_if_no_api_key <- function() {
if (Sys.getenv("API_KEY") == "") {
skip("API_KEY not available")
}
}
skip_if_slow <- function() {
if (!identical(Sys.getenv("RUN_SLOW_TESTS"), "true")) {
skip("Slow tests not enabled")
}
}
test_that("authenticated endpoint works", {
skip_if_no_api_key()
result <- call_authenticated_endpoint()
expect_equal(result$status, "success")
})Testing Flaky Code
retry with try_again()
Test code that may fail occasionally (network calls, timing-dependent code):
test_that("flaky network call succeeds eventually", {
result <- try_again(
times = 3,
{
response <- make_network_request()
expect_equal(response$status, 200)
response
}
)
expect_type(result, "list")
})Mark Tests as Flaky
test_that("timing-sensitive operation", {
skip_on_cran() # Too unreliable for CRAN
start <- Sys.time()
result <- async_operation()
duration <- as.numeric(Sys.time() - start)
expect_lt(duration, 2) # Should complete in < 2 seconds
})Managing Secrets in Tests
Environment Variables
test_that("authenticated API works", {
# Skip if credentials unavailable
api_key <- Sys.getenv("MY_API_KEY")
skip_if(api_key == "", "MY_API_KEY not set")
result <- call_api(api_key)
expect_true(result$authenticated)
})Local Configuration Files
test_that("service integration works", {
config_path <- test_path("fixtures", "test_config.yml")
skip_if_not(file.exists(config_path), "Test config not found")
config <- yaml::read_yaml(config_path)
result <- connect_to_service(config)
expect_true(result$connected)
})Never commit secrets:
- Add config files with secrets to
.gitignore - Use environment variables in CI/CD
- Provide example config files:
test_config.yml.example
Testing Without Secrets
Design tests to degrade gracefully:
test_that("API client works", {
api_key <- Sys.getenv("API_KEY")
if (api_key == "") {
# Mock the API when credentials unavailable
local_mocked_bindings(
make_api_call = function(...) list(status = "success", data = "mocked")
)
}
result <- my_api_wrapper()
expect_equal(result$status, "success")
})Custom Expectations
Create domain-specific expectations for clearer tests:
Simple Custom Expectations
# In helper-expectations.R
expect_valid_email <- function(email) {
expect_match(email, "^[^@]+@[^@]+\\.[^@]+$")
}
expect_positive <- function(x) {
expect_true(all(x > 0), info = "All values should be positive")
}
expect_named_list <- function(object, names) {
expect_type(object, "list")
expect_named(object, names, ignore.order = TRUE)
}Usage:
test_that("user validation works", {
user <- create_user("test@example.com")
expect_valid_email(user$email)
})Complex Custom Expectations
expect_valid_model <- function(model) {
act <- quasi_label(rlang::enquo(model))
expect(
inherits(act$val, "lm") && !is.null(act$val$coefficients),
sprintf("%s is not a valid linear model", act$lab)
)
invisible(act$val)
}State Inspection
Detect unintended global state changes:
# In setup-state.R
set_state_inspector(function() {
list(
options = options(),
env_vars = Sys.getenv(),
search = search()
)
})testthat will warn if state changes between tests.
CRAN-Specific Considerations
Time Limits
Tests must complete in under 1 minute:
test_that("slow operation completes", {
skip_on_cran() # Takes 2 minutes
result <- expensive_computation()
expect_equal(result$status, "complete")
})File System Discipline
Only write to temp directory:
test_that("file output works", {
# Good
output <- withr::local_tempfile(fileext = ".csv")
write.csv(data, output)
# Bad - writes to package directory
# write.csv(data, "output.csv")
})No External Dependencies
Avoid relying on:
- Network access
- External processes
- System commands
- Clipboard access
test_that("external dependency", {
skip_on_cran()
# Code requiring network or system calls
})Platform Differences
Use expect_equal() for numeric comparisons (allows tolerance):
test_that("calculation works", {
result <- complex_calculation()
# Good: tolerant to floating point differences
expect_equal(result, 1.234567)
# Bad: fails due to platform differences
# expect_identical(result, 1.234567)
})Test Performance
Identify Slow Tests
devtools::test(reporter = "slow")The SlowReporter highlights performance bottlenecks.
Test Shuffling
Detect unintended test dependencies:
# Randomly reorder tests
devtools::test(shuffle = TRUE)
# In test file
test_dir("tests/testthat", shuffle = TRUE)If tests fail when shuffled, they have unintended dependencies on execution order.
Parallel Testing
Enable parallel test execution in DESCRIPTION:
Config/testthat/parallel: trueRequirements for parallel tests:
- Tests must be independent
- No shared state between tests
- Use
local_*()functions for all side effects - Snapshot tests work correctly in parallel (testthat 3.2.0+)
Testing Edge Cases
Boundary Conditions
test_that("handles boundary conditions", {
expect_equal(my_func(0), expected_at_zero)
expect_equal(my_func(-1), expected_negative)
expect_equal(my_func(Inf), expected_infinite)
expect_true(is.nan(my_func(NaN)))
})Empty Inputs
test_that("handles empty inputs", {
expect_equal(process(character()), character())
expect_equal(process(NULL), NULL)
expect_equal(process(data.frame()), data.frame())
})Type Validation
test_that("validates input types", {
expect_error(my_func("string"), class = "vctrs_error_cast")
expect_error(my_func(list()), "must be atomic")
expect_no_error(my_func(1:10))
})Debugging Failed Tests
Interactive Debugging
# Run test interactively
devtools::load_all()
test_that("problematic test", {
# Add browser() to pause execution
browser()
result <- problematic_function()
expect_equal(result, expected)
})Print Debugging in Tests
test_that("debug output", {
data <- prepare_data()
print(str(data)) # Visible when test fails
result <- process(data)
print(result)
expect_equal(result, expected)
})Capture Output for Inspection
test_that("inspect messages", {
messages <- capture_messages(
result <- function_with_messages()
)
print(messages) # See all messages when test fails
expect_match(messages, "Processing complete")
})Testing R6 Classes
test_that("R6 class works", {
obj <- MyClass$new(value = 10)
expect_r6_class(obj, "MyClass") # testthat 3.3.0+
expect_equal(obj$value, 10)
obj$increment()
expect_equal(obj$value, 11)
})Testing S4 Classes
test_that("S4 validity works", {
obj <- new("MyClass", slot1 = 10, slot2 = "test")
expect_s4_class(obj, "MyClass")
expect_equal(obj@slot1, 10)
expect_error(
new("MyClass", slot1 = -1),
"slot1 must be positive"
)
})BDD-Style Testing with describe() and it()
Behavior-Driven Development (BDD) testing uses describe() and it() to create specification-style tests that read like natural language descriptions of behavior.
When to Use BDD Syntax
Use BDD (`describe`/`it`) when:
- Documenting intended behavior of new features
- Testing complex components with multiple related facets
- Following test-first development workflows
- Tests serve as specification documentation
- You want hierarchical organization of related tests
- A group of tests (in
it()statements) rely on a single fixture or local options/envvars (set up indescribe())
Use standard syntax (`test_that`) when:
- Writing straightforward unit tests
- Testing implementation details
- Converting existing test_that() tests (no need to change working code)
Key insight from testthat: "Use describe() to verify you implement the right things, use test_that() to ensure you do things right."
Basic BDD Syntax
Simple Specifications
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))
})
it("can compute determinant", {
m <- matrix(c(1, 2, 3, 4), 2, 2)
expect_equal(det(m), -2)
})
})Each it() block:
- Defines one specification (like
test_that()) - Runs in its own environment
- Has access to all expectations
- Can use withr and other testing tools
Nested Specifications
Group related specifications hierarchically:
describe("User authentication", {
describe("login()", {
it("accepts valid credentials", {
result <- login("user@example.com", "password123")
expect_true(result$authenticated)
expect_type(result$token, "character")
})
it("rejects invalid email", {
expect_error(
login("invalid-email", "password"),
class = "validation_error"
)
})
it("rejects wrong password", {
expect_error(
login("user@example.com", "wrong"),
class = "auth_error"
)
})
})
describe("logout()", {
it("clears session token", {
session <- create_session()
logout(session)
expect_null(session$token)
})
it("invalidates refresh token", {
session <- create_session()
logout(session)
expect_error(refresh(session), "Invalid token")
})
})
describe("password_reset()", {
it("sends reset email", {
local_mocked_bindings(send_email = function(...) TRUE)
result <- password_reset("user@example.com")
expect_true(result$email_sent)
})
it("generates secure token", {
result <- password_reset("user@example.com")
expect_gte(nchar(result$token), 32)
})
})
})Nesting creates clear hierarchies:
- Top level: Component or module
- Second level: Functions or features
- Third level: Specific behaviors
Pending Specifications
Mark unimplemented tests by omitting the code:
describe("division()", {
it("divides two numbers", {
expect_equal(division(10, 2), 5)
})
it("returns Inf for division by zero") # Pending
it("handles complex numbers") # Pending
})Pending tests:
- Show up in test output as "SKIPPED"
- Document planned functionality
- Serve as TODO markers
- Don't cause test failures
Complete Test File Example
# tests/testthat/test-data-processor.R
describe("DataProcessor", {
describe("initialization", {
it("creates processor with default config", {
proc <- DataProcessor$new()
expect_r6_class(proc, "DataProcessor")
expect_equal(proc$config$timeout, 30)
})
it("accepts custom configuration", {
proc <- DataProcessor$new(config = list(timeout = 60))
expect_equal(proc$config$timeout, 60)
})
it("validates configuration options", {
expect_error(
DataProcessor$new(config = list(timeout = -1)),
"timeout must be positive"
)
})
})
describe("process()", {
describe("with valid data", {
it("processes numeric data", {
proc <- DataProcessor$new()
result <- proc$process(data.frame(x = 1:10))
expect_s3_class(result, "data.frame")
expect_equal(nrow(result), 10)
})
it("handles missing values", {
proc <- DataProcessor$new()
data <- data.frame(x = c(1, NA, 3))
result <- proc$process(data)
expect_false(anyNA(result$x))
})
it("preserves column names", {
proc <- DataProcessor$new()
data <- data.frame(foo = 1:3, bar = 4:6)
result <- proc$process(data)
expect_named(result, c("foo", "bar"))
})
})
describe("with invalid data", {
it("rejects NULL input", {
proc <- DataProcessor$new()
expect_error(proc$process(NULL), "data cannot be NULL")
})
it("rejects empty data frame", {
proc <- DataProcessor$new()
expect_error(proc$process(data.frame()), "data cannot be empty")
})
it("rejects non-data.frame input", {
proc <- DataProcessor$new()
expect_error(proc$process(list()), class = "type_error")
})
})
})
describe("summary()", {
it("returns summary statistics", {
proc <- DataProcessor$new()
data <- data.frame(x = 1:10, y = 11:20)
proc$process(data)
summary <- proc$summary()
expect_type(summary, "list")
expect_named(summary, c("rows", "cols", "processed_at"))
})
it("throws error if no data processed", {
proc <- DataProcessor$new()
expect_error(proc$summary(), "No data has been processed")
})
})
})Organizing Files with BDD
Single Component per File
# tests/testthat/test-user-model.R
describe("User model", {
describe("validation", { ... })
describe("persistence", { ... })
describe("relationships", { ... })
})Multiple Related Components
# tests/testthat/test-math-operations.R
describe("arithmetic operations", {
describe("addition()", { ... })
describe("subtraction()", { ... })
describe("multiplication()", { ... })
describe("division()", { ... })
})Hierarchical Domain Organization
# tests/testthat/test-api-endpoints.R
describe("API endpoints", {
describe("/users", {
describe("GET /users", { ... })
describe("POST /users", { ... })
describe("GET /users/:id", { ... })
})
describe("/posts", {
describe("GET /posts", { ... })
describe("POST /posts", { ... })
})
})Mixing BDD and Standard Syntax
You can use both styles in the same test file:
# tests/testthat/test-calculator.R
# BDD style for user-facing functionality
describe("Calculator user interface", {
describe("button clicks", {
it("registers numeric input", { ... })
it("handles operator keys", { ... })
})
})
# Standard style for internal helpers
test_that("parse_expression() tokenizes correctly", {
tokens <- parse_expression("2 + 3")
expect_equal(tokens, c("2", "+", "3"))
})
test_that("evaluate_tokens() handles operator precedence", {
result <- evaluate_tokens(c("2", "+", "3", "*", "4"))
expect_equal(result, 14)
})Mixing guidelines:
- Use BDD for behavioral specifications
- Use
test_that()for implementation details - Keep related tests in the same style within a section
- Don't nest
test_that()insidedescribe()or vice versa
BDD with Test Fixtures
Use the same fixture patterns as standard tests:
describe("File processor", {
# Helper function for tests
new_test_file <- function(content) {
path <- withr::local_tempfile(lines = content)
path
}
describe("read_file()", {
it("reads text files", {
file <- new_test_file(c("line1", "line2"))
result <- read_file(file)
expect_length(result, 2)
})
it("handles empty files", {
file <- new_test_file(character())
result <- read_file(file)
expect_equal(result, character())
})
})
})BDD with Snapshot Tests
Snapshots work naturally with BDD:
describe("error messages", {
it("provides helpful validation errors", {
expect_snapshot(error = TRUE, {
validate_user(NULL)
validate_user(list())
validate_user(list(email = "invalid"))
})
})
it("shows clear permission errors", {
expect_snapshot(error = TRUE, {
check_permission("guest", "admin")
})
})
})BDD with Mocking
describe("API client", {
describe("fetch_user()", {
it("handles successful response", {
local_mocked_bindings(
http_get = function(url) {
list(status = 200, body = '{"id": 1, "name": "Test"}')
}
)
user <- fetch_user(1)
expect_equal(user$name, "Test")
})
it("handles 404 errors", {
local_mocked_bindings(
http_get = function(url) list(status = 404)
)
expect_error(fetch_user(999), class = "not_found_error")
})
})
})Test-First Workflow with BDD
1. Write specifications first:
describe("order_total()", {
it("sums item prices")
it("applies tax")
it("applies discount codes")
it("handles free shipping threshold")
})2. Implement one specification at a time:
describe("order_total()", {
it("sums item prices", {
order <- list(items = list(
list(price = 10),
list(price = 20)
))
expect_equal(order_total(order), 30)
})
it("applies tax")
it("applies discount codes")
it("handles free shipping threshold")
})3. Continue until all specs have implementations
This workflow ensures you:
- Think about requirements before implementation
- Have clear success criteria
- Build incrementally
- Maintain focus on behavior
Comparison: describe/it vs test_that
describe/it:
describe("str_length()", {
it("counts characters in string", {
expect_equal(str_length("abc"), 3)
})
it("handles empty strings", {
expect_equal(str_length(""), 0)
})
})test_that:
test_that("str_length() counts characters", {
expect_equal(str_length("abc"), 3)
})
test_that("str_length() handles empty strings", {
expect_equal(str_length(""), 0)
})Key differences:
- BDD groups related specs under
describe() - BDD uses "it" instead of "test_that"
- BDD enables nesting for hierarchy
- BDD supports pending specs without code
- Both produce identical test results
Choose based on your preferences and project style.
Test Fixtures and Data Management
Test fixtures arrange the environment into a known state for testing. testthat provides several approaches for managing test data and state.
Fixture Approaches
Constructor Helper Functions
Create reusable test objects on-demand:
# In tests/testthat/helper-fixtures.R or within test file
new_sample_data <- function(n = 10) {
data.frame(
id = seq_len(n),
value = rnorm(n),
category = sample(letters[1:3], n, replace = TRUE)
)
}
test_that("function handles data correctly", {
data <- new_sample_data(5)
result <- process_data(data)
expect_equal(nrow(result), 5)
})Advantages:
- Fresh data for each test
- Parameterizable
- No file I/O
Use when:
- Data is cheap to create
- Multiple tests need similar but not identical data
- Data should vary between tests
Local Functions with Cleanup
Handle side effects using withr::defer():
local_temp_csv <- function(data, pattern = "test", env = parent.frame()) {
path <- withr::local_tempfile(pattern = pattern, fileext = ".csv", .local_envir = env)
write.csv(data, path, row.names = FALSE)
path
}
test_that("CSV reading works", {
data <- data.frame(x = 1:3, y = letters[1:3])
csv_path <- local_temp_csv(data)
result <- read_my_csv(csv_path)
expect_equal(result, data)
# File automatically cleaned up after test
})Advantages:
- Automatic cleanup
- Encapsulates setup and teardown
- Composable
Use when:
- Tests create side effects (files, connections)
- Setup requires multiple steps
- Cleanup logic is non-trivial
Static Fixture Files
Store pre-created data files in tests/testthat/fixtures/:
tests/testthat/
├── fixtures/
│ ├── sample_data.rds
│ ├── config.json
│ └── reference_output.csv
└── test-processing.RAccess with test_path():
test_that("function processes real data", {
data <- readRDS(test_path("fixtures", "sample_data.rds"))
result <- process_data(data)
expected <- readRDS(test_path("fixtures", "expected_output.rds"))
expect_equal(result, expected)
})Advantages:
- Tests against real data
- Expensive-to-create data computed once
- Human-readable (for JSON, CSV, etc.)
Use when:
- Data is expensive to create
- Data represents real-world cases
- Multiple tests use identical data
- Data is complex or represents edge cases
Helper Files
Files in tests/testthat/ starting with helper- are automatically sourced before tests run.
# tests/testthat/helper-fixtures.R
# Custom expectations
expect_valid_user <- function(user) {
expect_type(user, "list")
expect_named(user, c("id", "name", "email"))
expect_type(user$id, "integer")
}
# Test data constructors
new_user <- function(id = 1L, name = "Test User", email = "test@example.com") {
list(id = id, name = name, email = email)
}
# Shared fixtures
standard_config <- function() {
list(
timeout = 30,
retries = 3,
verbose = FALSE
)
}Then use in tests:
test_that("user validation works", {
user <- new_user()
expect_valid_user(user)
})Setup Files
Files starting with setup- run only during R CMD check and devtools::test(), not during devtools::load_all().
# tests/testthat/setup-options.R
# Set options for test suite
withr::local_options(
list(
reprex.clipboard = FALSE,
reprex.html_preview = FALSE,
usethis.quiet = TRUE
),
.local_envir = teardown_env()
)Use setup files for:
- Package-wide test options
- Environment variable configuration
- One-time expensive operations
- Test suite initialization
Managing File System State
Use temp directories exclusively
test_that("file writing works", {
# Good: write to temp directory
path <- withr::local_tempfile(lines = c("line1", "line2"))
# Bad: write to working directory
# writeLines(c("line1", "line2"), "test_file.txt")
result <- process_file(path)
expect_equal(result, expected)
})Clean up automatically with withr
test_that("directory operations work", {
# Create temp dir that auto-cleans
dir <- withr::local_tempdir()
# Create files in it
file.create(file.path(dir, "file1.txt"))
file.create(file.path(dir, "file2.txt"))
result <- process_directory(dir)
expect_length(result, 2)
# Directory automatically removed after test
})Test files stored in fixtures
test_that("file parsing handles edge cases", {
# Read from committed fixture
malformed <- test_path("fixtures", "malformed.csv")
expect_warning(
result <- robust_read_csv(malformed),
"Malformed"
)
expect_true(nrow(result) > 0)
})Database Fixtures
In-memory SQLite
test_that("database queries work", {
con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
withr::defer(DBI::dbDisconnect(con))
# Create schema
DBI::dbExecute(con, "CREATE TABLE users (id INTEGER, name TEXT)")
DBI::dbExecute(con, "INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob')")
result <- query_users(con)
expect_equal(nrow(result), 2)
})Fixture SQL scripts
test_that("complex queries work", {
con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
withr::defer(DBI::dbDisconnect(con))
# Load schema from fixture
schema <- readLines(test_path("fixtures", "schema.sql"))
DBI::dbExecute(con, paste(schema, collapse = "\n"))
result <- complex_query(con)
expect_s3_class(result, "data.frame")
})Complex Object Fixtures
Save and load complex objects
Create fixtures interactively:
# Run once to create fixture
complex_model <- expensive_training_function(data)
saveRDS(complex_model, "tests/testthat/fixtures/trained_model.rds")Use in tests:
test_that("predictions work", {
model <- readRDS(test_path("fixtures", "trained_model.rds"))
new_data <- data.frame(x = 1:5, y = 6:10)
predictions <- predict(model, new_data)
expect_length(predictions, 5)
expect_type(predictions, "double")
})Fixture Organization
tests/testthat/
├── fixtures/
│ ├── data/ # Input data
│ │ ├── sample.csv
│ │ └── users.json
│ ├── expected/ # Expected outputs
│ │ ├── processed.rds
│ │ └── summary.txt
│ ├── models/ # Trained models
│ │ └── classifier.rds
│ └── sql/ # Database schemas
│ └── schema.sql
├── helper-constructors.R # Data constructors
├── helper-expectations.R # Custom expectations
├── setup-options.R # Test suite config
└── test-*.R # Test filesBest Practices
Keep fixtures small:
- Store minimal data needed for tests
- Use constructors for variations
- Commit fixtures to version control
Document fixture origins:
# tests/testthat/fixtures/README.md
# sample_data.rds
Created from production data on 2024-01-15
Contains 100 representative records with PII removed
# malformed.csv
Edge case discovered in issue #123
Contains intentional formatting errorsUse consistent paths:
# Always use test_path() for portability
data <- readRDS(test_path("fixtures", "data.rds"))
# Never use relative paths
# data <- readRDS("fixtures/data.rds") # BadPrefer deterministic fixtures:
# Good: reproducible
set.seed(123)
data <- data.frame(x = rnorm(10))
# Better: no randomness
data <- data.frame(x = seq(-2, 2, length.out = 10))Mocking in testthat
Mocking temporarily replaces function implementations during testing, enabling tests when dependencies are unavailable or impractical (databases, APIs, file systems, expensive computations).
Core Mocking Functions
local_mocked_bindings()
Replace function implementations within a test:
test_that("function works with mocked dependency", {
local_mocked_bindings(
get_user_data = function(id) {
list(id = id, name = "Test User", role = "admin")
}
)
result <- process_user(123)
expect_equal(result$name, "Test User")
})with_mocked_bindings()
Replace functions for a specific code block:
test_that("handles API failures gracefully", {
result <- with_mocked_bindings(
api_call = function(...) stop("Network error"),
{
tryCatch(
fetch_data(),
error = function(e) "fallback"
)
}
)
expect_equal(result, "fallback")
})S3 Method Mocking
Use local_mocked_s3_method() to mock S3 methods:
test_that("custom print method is used", {
local_mocked_s3_method(
print, "myclass",
function(x, ...) cat("Mocked output\n")
)
obj <- structure(list(), class = "myclass")
expect_output(print(obj), "Mocked output")
})S4 Method Mocking
Use local_mocked_s4_method() for S4 methods:
test_that("S4 method override works", {
local_mocked_s4_method(
"show", "MyS4Class",
function(object) cat("Mocked S4 output\n")
)
# Test code using the mocked method
})R6 Class Mocking
Use local_mocked_r6_class() to mock R6 classes:
test_that("R6 mock works", {
MockDatabase <- R6::R6Class("MockDatabase",
public = list(
query = function(sql) data.frame(result = "mocked")
)
)
local_mocked_r6_class("Database", MockDatabase)
db <- Database$new()
expect_equal(db$query("SELECT *"), data.frame(result = "mocked"))
})Common Mocking Patterns
Database Connections
test_that("database queries work", {
local_mocked_bindings(
dbConnect = function(...) list(connected = TRUE),
dbGetQuery = function(conn, sql) {
data.frame(id = 1:3, value = c("a", "b", "c"))
}
)
result <- fetch_from_db("SELECT * FROM table")
expect_equal(nrow(result), 3)
})API Calls
test_that("API integration works", {
local_mocked_bindings(
httr2::request = function(url) list(url = url),
httr2::req_perform = function(req) {
list(status_code = 200, content = '{"success": true}')
}
)
result <- call_external_api()
expect_true(result$success)
})File System Operations
test_that("file processing works", {
local_mocked_bindings(
file.exists = function(path) TRUE,
readLines = function(path) c("line1", "line2", "line3")
)
result <- process_file("dummy.txt")
expect_length(result, 3)
})Random Number Generation
test_that("randomized algorithm is deterministic", {
local_mocked_bindings(
runif = function(n, ...) rep(0.5, n),
rnorm = function(n, ...) rep(0, n)
)
result <- randomized_function()
expect_equal(result, expected_value)
})Advanced Mocking Packages
webfakes
Create fake web servers for HTTP testing:
test_that("API client handles responses", {
app <- webfakes::new_app()
app$get("/users/:id", function(req, res) {
res$send_json(list(id = req$params$id, name = "Test"))
})
web <- webfakes::local_app_process(app)
result <- get_user(web$url("/users/123"))
expect_equal(result$name, "Test")
})httptest2
Record and replay HTTP interactions:
test_that("API call returns expected data", {
httptest2::with_mock_dir("fixtures/api", {
result <- call_real_api()
expect_equal(result$status, "success")
})
})First run records real responses; subsequent runs replay them.
Mocking Best Practices
Mock at the right level:
- Mock external dependencies (APIs, databases)
- Don't mock internal package functions excessively
- Keep mocks simple and focused
Verify mock behavior:
test_that("mock is called correctly", {
calls <- list()
local_mocked_bindings(
external_func = function(...) {
calls <<- append(calls, list(list(...)))
"mocked"
}
)
my_function()
expect_length(calls, 1)
expect_equal(calls[[1]]$arg, "expected")
})Prefer real fixtures when possible:
- Use test data files instead of mocking file reads
- Use webfakes for full HTTP testing instead of mocking individual functions
- Mock only when fixtures are impractical
Document what's being mocked:
test_that("handles unavailable service", {
# Mock the external authentication service
# which is unavailable in test environment
local_mocked_bindings(
auth_check = function(token) list(valid = TRUE)
)
# test code
})Migration from Deprecated Functions
Old (deprecated):
with_mock(
pkg::func = function(...) "mocked"
)New (recommended):
local_mocked_bindings(
func = function(...) "mocked",
.package = "pkg"
)The new functions work with byte-compiled code and are more reliable across platforms.
Snapshot Testing
Snapshot tests record expected output in human-readable files rather than inline code. They are ideal for:
- Complex output that's difficult to verify programmatically
- User-facing messages, warnings, and errors
- Mixed output types (printed text + messages + warnings)
- Binary formats like plots
- Text with complex formatting
Basic Usage
test_that("error messages are helpful", {
expect_snapshot(
my_function(bad_input)
)
})The first run creates tests/testthat/_snaps/{test-file}/{test-name}.md containing the captured output.
Snapshot Workflow
Initial creation:
devtools::test() # Creates new snapshotsReview changes:
testthat::snapshot_review('test-name')Accept changes:
testthat::snapshot_accept('test-name')Reject changes:
testthat::snapshot_reject('test-name')Download snapshots from GitHub CI:
testthat::snapshot_download_gh()Snapshot Types
Output Snapshots
Capture printed output, messages, warnings, and errors:
test_that("function produces expected output", {
expect_snapshot({
print(my_data)
message("Processing complete")
warning("Non-critical issue")
})
})Value Snapshots
Capture the structure of R objects:
test_that("data structure is correct", {
expect_snapshot(str(complex_object))
})Error Snapshots
Capture error messages with call information:
test_that("errors are informative", {
expect_snapshot(
error = TRUE,
my_function(invalid_input)
)
})Transform Function
Use transform to remove variable elements before comparison:
test_that("output is stable", {
expect_snapshot(
my_api_call(),
transform = function(lines) {
# Remove timestamps
gsub("\\d{4}-\\d{2}-\\d{2}", "[DATE]", lines)
}
)
})Common uses:
- Remove timestamps or session IDs
- Normalize file paths
- Strip API keys or tokens
- Remove stochastic elements
Variants
Use variant for platform-specific or R-version-specific snapshots:
test_that("platform-specific behavior", {
expect_snapshot(
system_specific_function(),
variant = tolower(Sys.info()[["sysname"]])
)
})Variants save to _snaps/{variant}/{test}.md instead of _snaps/{test}.md.
Best Practices
- Commit snapshots to git - They are part of your test suite
- Review snapshot diffs carefully - Ensure changes are intentional
- Keep snapshots focused - One concept per snapshot
- Use transform for stability - Remove variable elements
- Update snapshots explicitly - Never auto-accept in CI
- Fail on new snapshots in CI - testthat 3.3.0+ does this automatically
Snapshot Files
Snapshots are stored as markdown files in tests/testthat/_snaps/:
tests/testthat/
├── test-utils.R
└── _snaps/
├── test-utils.md
└── windows/ # variant snapshots
└── test-utils.mdEach snapshot includes:
- Test name as heading
- Code that generated the output
- Captured output
Common Patterns
Testing error messages:
test_that("validation errors are clear", {
expect_snapshot(error = TRUE, {
validate_input(NULL)
validate_input("wrong type")
validate_input(numeric())
})
})Testing side-by-side comparisons:
test_that("diff output is readable", {
withr::local_options(width = 80)
expect_snapshot(
waldo::compare(expected, actual)
)
})Testing printed output with messages:
test_that("function provides feedback", {
expect_snapshot({
result <- process_data(sample_data)
print(result)
})
})Related skills
How it compares
Choose testing-r-packages for testthat 3 R package conventions rather than general polyglot unit-test skills aimed at JavaScript or Python.
FAQ
What does testing-r-packages use for R testing?
testing-r-packages uses testthat 3rd edition initialized through usethis::use_testthat(3). That creates tests/testthat/, adds testthat to DESCRIPTION Suggests, and sets Config/testthat/edition: 3.
How should test files be organized?
testing-r-packages mirrors package structure: code in R/foofy.R gets tests in tests/testthat/test-foofy.R. Pair creation uses usethis::use_r and usethis::use_test for consistent filenames.