Files
2025-11-30 08:48:12 +08:00

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))