Initial commit
This commit is contained in:
424
skills/testing-r-packages/SKILL.md
Normal file
424
skills/testing-r-packages/SKILL.md
Normal file
@@ -0,0 +1,424 @@
|
||||
---
|
||||
name: testing-r-packages
|
||||
description: >
|
||||
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.
|
||||
---
|
||||
|
||||
# Testing R Packages with testthat
|
||||
|
||||
Modern best practices for R package testing using testthat 3+.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
Initialize testing with testthat 3rd edition:
|
||||
|
||||
```r
|
||||
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 in `tests/testthat/test-foofy.R`
|
||||
- Use `usethis::use_r("foofy")` and `usethis::use_test("foofy")` to create paired files
|
||||
|
||||
**Special files:**
|
||||
- `helper-*.R` - Helper functions and custom expectations, sourced before tests
|
||||
- `setup-*.R` - Run during `R CMD check` only, not during `load_all()`
|
||||
- `fixtures/` - Static test data files accessed via `test_path()`
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests follow a three-level hierarchy: **File → Test → Expectation**
|
||||
|
||||
### Standard Syntax
|
||||
|
||||
```r
|
||||
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()`:
|
||||
|
||||
```r
|
||||
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 component
|
||||
- `it()` defines individual specifications (like `test_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](references/bdd.md) for comprehensive BDD patterns, nested specifications, and test-first workflows.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Three scales of testing:
|
||||
|
||||
**Micro** (interactive development):
|
||||
```r
|
||||
devtools::load_all()
|
||||
expect_equal(foofy(...), expected)
|
||||
```
|
||||
|
||||
**Mezzo** (single file):
|
||||
```r
|
||||
testthat::test_file("tests/testthat/test-foofy.R")
|
||||
# RStudio: Ctrl/Cmd + Shift + T
|
||||
```
|
||||
|
||||
**Macro** (full suite):
|
||||
```r
|
||||
devtools::test() # Ctrl/Cmd + Shift + T
|
||||
devtools::check() # Ctrl/Cmd + Shift + E
|
||||
```
|
||||
|
||||
## Core Expectations
|
||||
|
||||
### Equality
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
expect_match("Testing is fun!", "Testing")
|
||||
expect_match(text, "pattern", ignore.case = TRUE)
|
||||
```
|
||||
|
||||
### Structure and Type
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
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:
|
||||
|
||||
```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)
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Self-Contained Tests (Cleanup Side Effects)
|
||||
|
||||
Use `withr` to manage state changes:
|
||||
|
||||
```r
|
||||
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 options
|
||||
- `local_envvar()` - Temporarily set environment variables
|
||||
- `local_tempfile()` - Create temp file with automatic cleanup
|
||||
- `local_tempdir()` - Create temp directory with automatic cleanup
|
||||
- `local_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 of `library()`
|
||||
- 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](references/snapshots.md) for complete guide.
|
||||
|
||||
**Basic pattern:**
|
||||
|
||||
```r
|
||||
test_that("error message is helpful", {
|
||||
expect_snapshot(
|
||||
error = TRUE,
|
||||
validate_input(NULL)
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Snapshots stored in `tests/testthat/_snaps/`.
|
||||
|
||||
**Workflow:**
|
||||
```r
|
||||
devtools::test() # Creates new snapshots
|
||||
testthat::snapshot_review('name') # Review changes
|
||||
testthat::snapshot_accept('name') # Accept changes
|
||||
```
|
||||
|
||||
## Test Fixtures and Data
|
||||
|
||||
Three approaches for test data:
|
||||
|
||||
**1. Constructor functions** - Create data on-demand:
|
||||
```r
|
||||
new_sample_data <- function(n = 10) {
|
||||
data.frame(id = seq_len(n), value = rnorm(n))
|
||||
}
|
||||
```
|
||||
|
||||
**2. Local functions with cleanup** - Handle side effects:
|
||||
```r
|
||||
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:
|
||||
```r
|
||||
data <- readRDS(test_path("fixtures", "sample_data.rds"))
|
||||
```
|
||||
|
||||
See [references/fixtures.md](references/fixtures.md) for detailed fixture patterns.
|
||||
|
||||
## Mocking
|
||||
|
||||
Replace external dependencies during testing using `local_mocked_bindings()`. See [references/mocking.md](references/mocking.md) for comprehensive mocking strategies.
|
||||
|
||||
**Basic pattern:**
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
test_that("validation catches errors", {
|
||||
expect_error(
|
||||
validate_input("wrong_type"),
|
||||
class = "vctrs_error_cast"
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### Testing with Temporary Files
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```r
|
||||
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
|
||||
|
||||
```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)
|
||||
})
|
||||
```
|
||||
|
||||
## File System Discipline
|
||||
|
||||
**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")
|
||||
```
|
||||
|
||||
## 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)`
|
||||
Reference in New Issue
Block a user