7.4 KiB
7.4 KiB
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.R
Access 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 files
Best 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 errors
Use consistent paths:
# Always use test_path() for portability
data <- readRDS(test_path("fixtures", "data.rds"))
# Never use relative paths
# data <- readRDS("fixtures/data.rds") # Bad
Prefer 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))