When repetition breaks your test suite
You write a function that parses a configuration string. It handles valid inputs, trims whitespace, and rejects malformed JSON. You write one test. It passes. Then you realize you need to test empty strings, trailing commas, missing keys, and unicode edge cases. You copy the test function five times. You change one line each time. Suddenly your test file is a wall of nearly identical code. Changing the setup logic means editing six separate functions. The repetition is a maintenance trap.
The parameterized pattern
Parameterized testing solves this by separating the test logic from the test data. You write the assertion once. You list the inputs separately. The test runner executes the same function against every row of data. Think of it like a spreadsheet. The formula lives in one cell. You paste it down a column. Each row gets its own result. rstest brings this pattern to Rust. It uses procedural macros to generate multiple test functions at compile time, each bound to a specific set of arguments.
The crate does not run tests at runtime. It rewrites your source code before the compiler sees it. You get the readability of a data table with the performance of native Rust test functions.
Minimal example
Add rstest to your Cargo.toml under [dev-dependencies]. Apply the #[rstest] attribute to a test function. Define your inputs with #[case] attributes above the function. Bind them to parameters using #[case] inside the signature.
use rstest::rstest;
/// Verifies addition across multiple input pairs without duplicating the assertion.
#[rstest]
#[case(1, 2, 3)]
#[case(4, 5, 9)]
#[case(-1, 1, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
// The macro expands this into three separate #[test] functions.
// Each one receives the values from its corresponding #[case] row.
// We keep the assertion minimal to highlight the data-driven structure.
assert_eq!(a + b, expected);
}
Keep your case rows aligned vertically. The community treats the attribute list as a table. Vertical alignment makes missing values or type mismatches obvious at a glance.
What the compiler actually does
When you run cargo test, the Rust compiler processes the #[rstest] macro before it sees your actual test code. The macro scans the #[case] attributes. It finds three rows. It generates three distinct test functions behind the scenes. Each generated function has a unique name like test_add_case_1, test_add_case_2, and test_add_case_3. The values from each row are injected directly into the function parameters. Your single assert_eq! runs three times. The test harness reports three separate results. If the second case fails, the output points exactly to test_add_case_2. You never write the duplication yourself. The compiler handles the expansion.
The macro also respects Rust's standard test filtering. Running cargo test test_add_case_2 executes only that specific row. Running cargo test test_add matches the base name and runs all generated variants. The test harness sees them as independent functions. They run in parallel by default. They do not share state. Each case gets a fresh stack frame.
Treat the generated names as implementation details. Never rely on them in external scripts. Use the base function name for filtering.
Realistic setup with fixtures
Real tests rarely just pass integers. They usually need objects, file handles, or database connections. rstest provides the #[fixture] attribute for shared setup logic. A fixture is a function that returns a value. The macro calls it automatically and injects the result into your test parameters. You can also use #[default] to provide fallback values and #[values] to define inline alternatives without repeating the full #[case] syntax.
use rstest::{fixture, rstest};
/// Creates a fresh configuration object for each test run.
/// Marking it as a fixture lets rstest call it automatically.
#[fixture]
fn config_template() -> String {
// Simulates loading a base configuration from disk.
// Each test gets its own isolated copy to prevent cross-test pollution.
String::from("host=localhost\nport=8080")
}
/// Tests configuration parsing with different overrides and defaults.
#[rstest]
#[case("port=9090", 9090)]
#[case("host=127.0.0.1", 8080)]
fn test_config_override(
#[fixture] base: String,
#[case] override_line: &str,
#[case] expected_port: u16,
) {
// Combine the base configuration with the test-specific override.
// We use format! to simulate merging config files line by line.
let full_config = format!("{}\n{}", base, override_line);
// Parse logic would go here. We simulate it for clarity.
// The macro guarantees `base` is owned, so we can safely manipulate it.
let parsed_port = if override_line.starts_with("port=") {
override_line.split('=').nth(1).unwrap().parse::<u16>().unwrap()
} else {
8080
};
assert_eq!(parsed_port, expected_port);
}
Fixtures run before each test case. They do not run once per file. If you have ten cases and one fixture, the fixture function executes ten times. This isolation prevents state leakage between cases. It also means expensive setup will multiply your test suite runtime. Profile your fixtures. Drop heavy allocations behind #[cfg(test)] mocks when possible.
Pitfalls and compiler friction
The macro is strict about matching data to parameters. If you provide four values in a #[case] row but only declare three #[case] parameters, the macro expansion fails. You will see a compiler error pointing to the test function signature. The message usually complains about mismatched argument counts or unexpected tokens. If your types do not align, you get E0308 (mismatched types). The macro injects the literal values from the attribute, so #[case("123")] will not automatically coerce into an i32 parameter. You must parse it inside the test or provide the correct type in the case definition.
Another common trap involves lifetimes. If a fixture returns a String and your test expects a &str, the macro handles the coercion automatically in most cases. If you return a reference from a fixture, you will hit lifetime errors. Fixtures should own their data. Return String, Vec<T>, or custom structs. Never return a reference to a local variable created inside the fixture. The borrow checker will reject it with E0515 (cannot return reference to local variable) before the macro even finishes expanding.
Panic handling also behaves differently than expected. If a fixture panics, the test case fails immediately. The failure message points to the fixture function, not the test body. This is intentional. It forces you to isolate setup failures from assertion failures. If your test logic panics, the output points to the case number. Keep fixtures free of unwrap() on external I/O. Return Result types and handle errors inside the test body.
The macro does not reorder test execution. cargo test runs tests in an undefined order by default. Do not assume case_1 runs before case_2. If your tests depend on execution order, you are writing flaky tests. Fix the dependency instead of fighting the runner.
When to reach for rstest
Use standard #[test] functions when you have a single, self-contained scenario that does not repeat. Use rstest when you have the same assertion logic running against multiple input combinations. Use rstest fixtures when setup code exceeds three lines or involves external resources like temporary files or mock servers. Reach for property-based testing crates like proptest when you need to generate thousands of random inputs to find edge cases rather than hand-picking specific values. Stick to rstest when you want explicit, readable test data that your team can review line by line.
Treat your test data as documentation. If a case fails six months from now, the #[case] row should tell you exactly what input triggered the regression without reading the function body.