How to use rstest

Install rstest via Cargo and use the #[rstest] macro to create parameterized tests with fixtures.

The copy-paste trap

You write a function to parse a configuration string. You write a test. It passes. Then you remember that the function should handle empty strings, missing keys, and malformed JSON. You copy the test, paste it, change the input, change the assertion. You repeat this five times.

Now you have a wall of identical code. The only difference is the string literal and the expected result. You spot a typo in the third test's assertion. You fix it. Then you realize the typo exists in the seventh test too. You fix that. Two weeks later, you add a new edge case. You copy-paste again. The test suite grows, the duplication grows, and the signal-to-noise ratio drops.

This is the copy-paste trap. It makes tests fragile and hard to maintain. rstest breaks the trap. It lets you define a test function once and feed it multiple sets of inputs. The macro expands your code at compile time into separate test functions. You get the isolation of individual tests with the brevity of a data table.

Parameterized tests: one function, many cases

rstest is a crate that provides the #[rstest] macro. You apply this macro to a test function. Inside the function signature, you mark arguments with #[case]. Above the function, you list the values using #[case(...)] attributes.

The macro reads the cases and generates a distinct test for each combination. If you have three cases, you get three tests. Each test runs independently. If one fails, the others still execute. The test runner reports them as test_name::case_1, test_name::case_2, and so on. This granularity is essential for CI pipelines and debugging.

use rstest::rstest;

/// Test the parser with explicit inputs and expected outputs.
#[rstest]
// Each #[case] defines a row of inputs.
// The order of values matches the order of #[case] arguments.
#[case("key=value", Ok(("key", "value")))]
#[case("", Err(ParseError::Empty))]
#[case("missing_equals", Err(ParseError::MissingValue))]
fn test_parse(
    #[case] input: &str,
    #[case] expected: Result<(&str, &str), ParseError>,
) {
    // The function body runs once per case.
    // The macro injects the values from the matching #[case] attribute.
    let result = parse(input);
    assert_eq!(result, expected);
}

The #[case] attribute on the argument binds the value to the parameter. You can have multiple #[case] arguments. The macro pairs them positionally. The first value in #[case(a, b)] goes to the first #[case] argument. The second value goes to the second argument.

Convention aside: name your case arguments descriptively. #[case] input is better than #[case] s. The test output includes the argument names when debugging, so clear names save time when a test fails.

Fixtures: reusable setup

Parameterized tests handle data variation. Fixtures handle setup variation. A fixture is a function that prepares a resource for a test. You mark a function with #[fixture]. Then you inject the fixture into a test by adding it as an argument.

Fixtures run before the test. If the fixture returns a type that implements Drop, the destructor runs after the test. This pattern lets you create temporary resources and clean them up automatically.

use rstest::fixture;
use std::fs;
use std::path::PathBuf;

/// Create a temporary directory for file-based tests.
/// Returns a guard that deletes the directory on drop.
#[fixture]
fn temp_dir() -> TempDirGuard {
    let path = std::env::temp_dir().join("rstest_example");
    fs::create_dir_all(&path).unwrap();
    // Return a guard struct.
    // The Drop impl will remove the directory when the test finishes.
    TempDirGuard(path)
}

/// A simple guard that cleans up the directory on drop.
struct TempDirGuard(PathBuf);

impl Drop for TempDirGuard {
    fn drop(&mut self) {
        // Best effort cleanup.
        // Ignore errors in drop to avoid panicking during cleanup.
        let _ = fs::remove_dir_all(&self.0);
    }
}

/// Use the fixture in a test.
/// rstest calls temp_dir() before running this test.
#[rstest]
fn test_write_file(temp_dir: TempDirGuard) {
    // Access the path via the guard.
    let file_path = temp_dir.0.join("data.txt");
    fs::write(&file_path, "hello").unwrap();
    assert!(file_path.exists());
    // temp_dir is dropped here, cleaning up the directory.
}

Fixtures can depend on other fixtures. If a test needs a database connection and a temp directory, you can inject both. rstest resolves the dependency graph and calls fixtures in the correct order.

Keep fixtures focused. A fixture should set up one resource. Don't create a setup_everything fixture that spins up a database, a cache, and a mock server. Split it into db_pool, cache_client, and mock_server. Composable fixtures are easier to reuse and debug.

Parameterized fixtures and the #[with] attribute

Fixtures can accept parameters. This is useful when you need the same setup with different configurations. Use the #[with] attribute to pass values to a fixture. You can also provide default values using #[default(...)].

use rstest::{fixture, rstest};

/// Create a database URL.
/// Defaults to an in-memory SQLite database.
/// Tests can override this with #[with("postgres://...")].
#[fixture]
fn db_url(#[default("sqlite::memory:")] url: &str) -> String {
    // In a real app, you might validate the URL here.
    url.to_string()
}

/// Test connection with different database backends.
#[rstest]
// Override the default for the first case.
#[with("postgres://localhost/test")]
// Use the default for the second case.
#[case("sqlite::memory:")]
fn test_connect(db_url: String, #[case] expected_url: &str) {
    assert_eq!(db_url, expected_url);
    // Connect logic would go here.
}

The #[with] attribute applies to the fixture argument in the test. If you have multiple fixtures, you can target a specific one by name. This keeps the test signature clean while allowing fine-grained control over setup.

Convention aside: use #[default] for the most common configuration. This reduces boilerplate in tests that don't need special setup. Only override the default when the test requires a specific variant.

Pitfalls: combinatorial explosion and trait bounds

rstest is powerful, but it has traps. The most common is combinatorial explosion. When you mix fixtures and cases, rstest generates the Cartesian product. If you have two fixtures and three cases, you get six tests. Add a third fixture with four variants, and you get twenty-four tests.

Count the combinations before you commit. If the number grows too large, the test suite slows down, and failures become harder to diagnose. Split large test groups into smaller functions. Use separate test modules for different combinations.

Another pitfall involves trait bounds. rstest often requires parameters to implement Clone or Debug. The macro may clone values to pass them to multiple test invocations or debug them in error messages. If you pass a type that doesn't implement these traits, the compiler rejects the code.

You'll see an error like E0277 (the trait bound ...: Clone is not satisfied). The fix is usually to implement the trait for your type or wrap the value in a reference if cloning is expensive. For heavy resources, prefer fixtures that return references or guards rather than passing large values as case parameters.

Macro errors can also be opaque. If the macro fails to expand, the error might point to the macro invocation rather than the specific case. Check the #[case] syntax carefully. Ensure the number of values matches the number of #[case] arguments. Mismatches cause expansion failures that can look like unrelated syntax errors.

Watch the combinatorial explosion. Two fixtures and three cases make six tests. Three fixtures make eighteen. Count before you commit.

Decision: rstest vs proptest vs standard tests

Choose the right tool for the shape of your data.

Use rstest when you have a fixed set of explicit inputs that exercise different branches of your code. Use rstest when you need reusable setup logic across many tests. Use rstest when you want to parameterize fixtures with #[with] to test different configurations. Use rstest with the timeout feature when you need to enforce execution limits on tests, especially for async code.

Use standard #[test] when you have a single, unique scenario that doesn't fit a pattern. Use standard #[test] when the setup is trivial and doesn't warrant a fixture. Use standard #[test] when you need to test complex state transitions that are hard to express as independent cases.

Use proptest when you want to generate random inputs and find edge cases automatically. Use proptest when you care about properties of your code rather than specific inputs. Use proptest when you suspect your code has hidden bugs that manual cases might miss.

Pick the tool that matches your data. Explicit cases get rstest. Random properties get proptest. Unique scenarios get standard tests.

Where to go next