When copy-pasting tests becomes a trap
You have a function that parses a configuration string. You write a test for "key=value". It passes. You write another test for "key= value". It passes. Now you need to check empty strings, missing values, and special characters. You copy-paste the test function again and again, changing just the input and the assertion. Your test file is growing, but the logic is identical. You're drowning in boilerplate.
rstest solves this by letting you define the test logic once and feed it a list of inputs. You write the assertion one time. You list the cases. The crate generates the rest.
Stop copy-pasting. Parameterize.
What rstest actually does
Rust's built-in #[test] attribute only supports standalone functions. You cannot pass arguments to a test function. If you want to run the same check against multiple inputs, you have to write multiple functions or use a loop inside one function. Loops hide which input failed. Multiple functions create repetition.
rstest bridges this gap using a procedural macro. A procedural macro runs during compilation and transforms your code. You annotate a function with #[rstest] and mark parameters with #[case]. The macro reads your annotations and generates separate test functions for each case. Each generated function gets a unique name like test_add::case_1. When a test fails, the output points to the specific case.
The macro rewrites your code before the compiler sees it. You get clean syntax and generated tests without runtime overhead.
Minimal example
Add rstest as a dev-dependency. This keeps the crate out of your production binary.
# Cargo.toml
[dev-dependencies]
rstest = "0.23"
Write a parameterized test. The #[rstest] attribute triggers the macro. Each #[case] attribute provides a set of values. The parameters must be marked with #[case] to match the values.
// src/lib.rs
use rstest::rstest;
/// Tests addition with multiple input pairs.
#[rstest]
#[case(2, 4, 6)]
#[case(10, 20, 30)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
// Verify the sum matches the expected result for this case.
assert_eq!(a + b, expected);
}
Run the tests with cargo test. The output shows distinct entries for each case.
running 2 tests
test tests::test_add::case_1 ... ok
test tests::test_add::case_2 ... ok
test result: ok. 2 passed; 0 failed
If the second case fails, the error references case_2. You see exactly which input broke.
Run cargo test. Watch the cases appear.
Fixtures for reusable setup
Real tests often need setup. You might need a mock database connection, a temporary file, or a complex configuration object. Writing this setup inside every test function repeats code. rstest handles this with fixtures.
A fixture is a function marked with #[fixture]. It returns a value. You can inject fixtures into tests as parameters. The macro calls the fixture before running the test and passes the result.
use rstest::{fixture, rstest};
/// Creates a mock database connection string.
/// The #[fixture] attribute makes this function injectable.
#[fixture]
fn mock_db() -> String {
"sqlite::memory".to_string()
}
/// Tests query building with different filters.
#[rstest]
#[case("name", "Alice", "SELECT * FROM users WHERE name = 'Alice'")]
#[case("age", "25", "SELECT * FROM users WHERE age = '25'")]
fn test_query_builder(
#[case] field: &str,
#[case] value: &str,
#[case] expected: &str,
// Inject the fixture directly as a parameter.
#[from(mock_db)] db: String,
) {
// Simulate building a query using the db connection.
let query = format!("SELECT * FROM users WHERE {} = '{}'", field, value);
assert_eq!(query, expected);
// Verify the fixture was injected correctly.
assert_eq!(db, "sqlite::memory");
}
Fixtures run once per test case. Each case gets a fresh instance. Side effects in one case do not leak to another.
Convention aside: When injecting fixtures, prefer #[from(fixture_name)] even if the parameter name matches. It makes the dependency graph obvious to readers. #[from(mock_db)] db: String signals that this test requires mock_db. A bare db: String hides that fact until you hunt for the fixture definition.
Inject fixtures like parameters. Keep setup out of the test body.
Matrix testing for combinations
Sometimes you need to test every combination of inputs. Maybe you have two fixtures and want to test them together. rstest supports cartesian products. You can declare multiple parameters or fixtures in the #[rstest] attribute, and the macro generates a test for every combination.
use rstest::{fixture, rstest};
/// Fixture providing a list of database drivers.
#[fixture]
fn drivers() -> Vec<&'static str> {
vec!["postgres", "mysql"]
}
/// Fixture providing a list of query modes.
#[fixture]
fn modes() -> Vec<&'static str> {
vec!["read", "write"]
}
/// Tests connection initialization for all driver and mode combinations.
#[rstest]
fn test_connect(
#[from(drivers)] driver: &str,
#[from(modes)] mode: &str,
) {
// Verify the connection string format for this combination.
let expected = format!("{}://localhost/{}", driver, mode);
assert_eq!(expected, expected);
}
This generates four tests: postgres with read, postgres with write, mysql with read, and mysql with write. The macro handles the nesting. You don't write nested loops.
Use matrix testing for combinations. Avoid nested loops in your tests.
Pitfalls and compiler errors
If you forget the #[case] attribute on a parameter, the macro doesn't recognize it as a parameterized argument. The generated code tries to call the function without that argument, or with the wrong signature. The compiler rejects this with E0425 (cannot find value) or a mismatched arguments error. Always mark parameters with #[case].
If your case values don't match the parameter types, you get E0308 (mismatched types). rstest passes values by position. The first value in #[case] goes to the first #[case] parameter. Check the order and types carefully.
Fixtures return values. If you need a reference, the fixture must return a reference or you must wrap the value in Rc. Returning a reference to a local variable inside a fixture causes a lifetime error. The fixture runs, returns, and the local scope ends. The reference would dangle. Return owned types or use Rc for shared references.
Mark parameters with #[case]. The macro needs the signal.
When to use rstest
Use rstest when you have a fixed set of inputs and want to run the same assertion logic against all of them. It keeps your test file clean and gives you readable output per case.
Use rstest when you need reusable setup code across multiple tests. Fixtures let you define the setup once and inject it wherever needed.
Use rstest when you want to test combinations of inputs. Matrix testing generates the cartesian product automatically.
Use built-in #[test] when the test logic is unique and doesn't repeat. Parameterized testing adds macro overhead that isn't worth it for a single, one-off check.
Use proptest when you want to generate random inputs and find edge cases automatically. rstest requires you to list every case manually. proptest explores the input space for you.
Use criterion when you need to measure performance. rstest is for correctness, not benchmarks.
Pick the tool that matches the test shape. rstest for lists, proptest for exploration.