- name
- Testing Patterns with testthat
- description
- Comprehensive guide to testing R packages using testthat 3rd edition, including test structure, expectations, fixtures, and snapshot testing
# Testing Patterns with testthat
## Overview
Testing is essential for reliable R packages. This skill covers testthat 3rd edition, the standard testing framework for R packages, including test structure, expectations, fixtures, and advanced patterns.
## Setup
### Initial Setup
```r
# Setup testthat 3rd edition:
usethis::use_testthat(3)
```
This creates:
```
tests/
├── testthat/
│ └── (test files will go here)
└── testthat.R
```
And adds to DESCRIPTION:
```dcf
Suggests:
testthat (>= 3.0.0)
Config/testthat/edition: 3
```
### Key Differences: Edition 3 vs 2
**Edition 3 changes:**
- `context()` deprecated (use file names)
- More informative error messages
- Better snapshot testing
- Improved parallel test support
- Stricter comparison defaults
```r
# OLD (Edition 2):
context("My feature tests")
expect_equal(x, y, tolerance = 1e-8)
# NEW (Edition 3):
# No context() - file name is context
expect_equal(x, y, tolerance = 1e-8) # Same, but better errors
```
## Test File Structure
### Basic Structure
```r
# tests/testthat/test-my-feature.R
test_that("basic functionality works", {
result <- my_function(1:10)
expect_equal(result, (1:10) * 2)
expect_length(result, 10)
})
test_that("handles edge cases", {
expect_equal(my_function(numeric(0)), numeric(0))
expect_error(my_function(NULL), class = "error")
})
test_that("parameter validation works", {
expect_error(my_function("not numeric"), "must be numeric")
expect_warning(my_function(c(1, NA)), "NA values detected")
})
```
### File Naming Convention
Mirror your R/ file structure:
```
R/
├── data-processing.R
├── visualization.R
└── utils.R
tests/testthat/
├── test-data-processing.R
├── test-visualization.R
└── test-utils.R
```
**Rules:**
- Files MUST start with `test-`
- Use descriptive names matching R/ files
- One test file per R source file (usually)
- Or organize by feature/functionality
### Test Organization Within Files
```r
# tests/testthat/test-statistics.R
# Group related tests
test_that("mean calculation is correct", {
expect_equal(my_mean(1:10), 5.5)
expect_equal(my_mean(c(1, 2, 3)), 2)
})
test_that("mean handles NA values", {
expect_equal(my_mean(c(1, NA, 3), na.rm = TRUE), 2)
expect_true(is.na(my_mean(c(1, NA, 3), na.rm = FALSE)))
})
test_that("mean validates input", {
expect_error(my_mean("not numeric"))
expect_error(my_mean(list(1, 2, 3)))
})
# Separate feature
test_that("median calculation is correct", {
expect_equal(my_median(1:10), 5.5)
expect_equal(my_median(1:11), 6)
})
```
## Core Expectations
### expect_equal()
Tests near equality (with tolerance for numerics).
```r
test_that("numeric equality works", {
# Exact equality:
expect_equal(1 + 1, 2)
# With tolerance (default 1.5e-8):
expect_equal(sqrt(2)^2, 2)
# Custom tolerance:
expect_equal(1.00001, 1, tolerance = 1e-4)
# Vectors:
expect_equal(1:5, c(1, 2, 3, 4, 5))
# Data frames:
expect_equal(
data.frame(x = 1:3, y = 4:6),
data.frame(x = 1:3, y = 4:6)
)
# Ignore attributes:
expect_equal(
c(a = 1, b = 2),
c(1, 2),
ignore_attr = TRUE
)
})
```
### expect_identical()
Tests exact identity (no tolerance).
```r
test_that("exact identity works", {
# Integers:
expect_identical(1L, 1L)
# But not with doubles:
expect_failure(expect_identical(1, 1L)) # Different types
# Attributes matter:
expect_failure(
expect_identical(
c(a = 1, b = 2),
c(1, 2)
)
)
# Use for reference comparisons:
x <- 1:10
y <- x
expect_identical(x, y)
# Type checking:
expect_identical(class(x), "integer")
})
```
### expect_error()
Tests that code throws an error.
```r
test_that("errors are thrown correctly", {
# Basic error:
expect_error(stop("oops"))
# Error with specific message (regex):
expect_error(
stop("value must be numeric"),
"must be numeric"
)
# Error with specific class (PREFERRED):
expect_error(
my_function(invalid_input),
class = "invalid_input_error"
)
# Both class and message:
expect_error(
my_function(NULL),
"cannot be NULL",
class = "null_input_error"
)
})
```
**Best practice:** Use custom error classes and test them:
```r
# In your package:
validate_input <- function(x) {
if (!is.numeric(x)) {
rlang::abort(
"Input must be numeric",
class = "invalid_input_error"
)
}
}
# In tests:
test_that("validation errors have correct class", {
expect_error(
validate_input("text"),
class = "invalid_input_error"
)
})
```
### expect_warning()
Tests that code produces warnings.
```r
test_that("warnings are issued correctly", {
# Basic warning:
expect_warning(warning("careful!"))
# Warning with message:
expect_warning(
my_function(c(1, NA)),
"NA values detected"
)
# Warning with class:
expect_warning(
my_function(x),
class = "deprecated_argument"
)
})
```
### expect_message()
Tests that code produces messages.
```r
test_that("messages are printed correctly", {
expect_message(message("Processing..."))
expect_message(
my_function(verbose = TRUE),
"Starting computation"
)
# Multiple messages:
expect_message(
expect_message(
my_verbose_function(),
"Step 1"
),
"Step 2"
)
})
```
### expect_no_error() / expect_no_warning() / expect_no_message()
Tests that code runs without conditions.
```r
test_that("clean execution", {
# No errors:
expect_no_error(my_function(valid_input))
# No warnings:
expect_no_warning(my_function(good_data))
# No messages:
expect_no_message(my_function(verbose = FALSE))
})
```
### Other Useful Expectations
```r
test_that("various expectations work", {
# Truth values:
expect_true(2 + 2 == 4)
expect_false(2 + 2 == 5)
# NULL:
expect_null(NULL)
expect_null(my_function_returning_null())
# Type checks:
expect_type(1:10, "integer")
expect_type(letters, "character")
# Class checks:
expect_s3_class(lm(y ~ x, data), "lm")
expect_s4_class(object, "myS4class")
# Length/dimensions:
expect_length(1:10, 10)
expect_length(list(a = 1, b = 2), 2)
# Named:
expect_named(c(a = 1, b = 2), c("a", "b"))
# Vector type and length:
expect_vector(1:10, ptype = integer(), size = 10)
# Matching:
expect_match("hello world", "hello")
expect_match("abc123", "\\d+") # Regex
# Set operations:
expect_setequal(c(1, 2, 3), c(3, 2, 1)) # Order doesn't matter
expect_contains(1:10, c(5, 7, 9)) # Subset
# Invisible return:
expect_invisible(invisible(42))
# Output:
expect_output(print("hello"), "hello")
})
```
## Snapshot Testing
Test output that's hard to describe with expectations.
### expect_snapshot()
Captures printed output, messages, warnings, and errors.
```r
test_that("function output is correct", {
expect_snapshot({
my_complex_function()
})
})
```
First run creates `tests/testthat/_snaps/my-test.md`:
```md
# function output is correct
Code
my_complex_function()
Output
Processing data...
Results:
Mean: 5.5
SD: 2.87
```
Subsequent runs compare against snapshot. Update with:
```r
testthat::snapshot_review()
# Or:
testthat::snapshot_accept()
```
### Snapshot Variants
```r
test_that("snapshots capture different outputs", {
# Just messages:
expect_snapshot(
message("Hello"),
cnd_class = TRUE # Include condition class
)
# Errors (with class):
expect_snapshot(
error = TRUE,
my_function(invalid)
)
# Transforming output:
expect_snapshot(
my_function(),
transform = scrub_randomness
)
# Multiple variants:
expect_snapshot({
cat("Output 1\n")
message("Message 1")
cat("Output 2\n")
})
})
```
### expect_snapshot_output()
Specifically for printed output (deprecated, use expect_snapshot()).
```r
test_that("print methods work", {
expect_snapshot_output(print(my_object))
})
```
### expect_snapshot_value()
Captures R object structure.
```r
test_that("complex return values are correct", {
result <- my_complex_function()
expect_snapshot_value(
result,
style = "json2" # or "serialize", "deparse"
)
})
```
### When to Use Snapshots
**Good for:**
- Complex printed output (print methods, summaries)
- Error messages (ensures consistent UX)
- Multi-line formatted output
- Plots (as text representation)
**Avoid for:**
- Simple values (use expect_equal())
- When snapshots are hard to review
- When output changes frequently
## Test Helpers and Fixtures
### Helper Files
Files starting with `helper-` run before tests and make utilities available:
```r
# tests/testthat/helper-data.R
# Create test data used across multiple test files:
make_test_data <- function(n = 100) {
data.frame(
id = seq_len(n),
value = rnorm(n),
category = sample(LETTERS[1:3], n, replace = TRUE)
)
}
# Create test fixtures:
sample_data <- make_test_data()
# Utility assertions:
expect_valid_output <- function(x) {
expect_s3_class(x, "data.frame")
expect_true(nrow(x) > 0)
expect_named(x, c("id", "result"))
}
```
Use in tests:
```r
# tests/testthat/test-analysis.R
test_that("analysis works with test data", {
result <- analyze(sample_data)
expect_valid_output(result)
})
```
### Setup Files
`setup.R` runs once before all tests:
```r
# tests/testthat/setup.R
View on GitHub