Parameterized Tests in Rust with rstest: Fixtures and Test Cases

Parameterized Tests in Rust with rstest: Fixtures and Test Cases

Standard Rust tests don't support parameterized test cases or reusable fixtures out of the box. The rstest crate fills that gap with #[case], #[fixture], #[values], and matrix test generation — all integrating cleanly with cargo test.

Installation

[dev-dependencies]
rstest = "0.23"

#[case]: Parameterized Test Cases

Without rstest, testing multiple inputs requires duplicating tests or writing a loop:

// tedious: one test per input
#[test]
fn test_is_even_2() { assert!(is_even(2)); }
#[test]
fn test_is_even_4() { assert!(is_even(4)); }

With rstest's #[case]:

use rstest::rstest;

#[rstest]
#[case(2, true)]
#[case(3, false)]
#[case(4, true)]
#[case(0, true)]
#[case(-1, false)]
fn test_is_even(#[case] n: i32, #[case] expected: bool) {
    assert_eq!(is_even(n), expected);
}

Each #[case] generates a separate test function. Running cargo test shows:

test test_is_even::case_1 ... ok
test test_is_even::case_2 ... ok
test test_is_even::case_3 ... ok
test test_is_even::case_4 ... ok
test test_is_even::case_5 ... ok

Cases are individually named, individually runnable, and individually reported.

Named Cases

Give cases descriptive names for clearer output:

#[rstest]
#[case::zero_is_even(0, true)]
#[case::one_is_odd(1, false)]
#[case::negative_even(-2, true)]
fn test_parity(#[case] n: i32, #[case] expected: bool) {
    assert_eq!(is_even(n), expected);
}

Output:

test test_parity::zero_is_even ... ok
test test_parity::one_is_odd ... ok
test test_parity::negative_even ... ok

#[values]: Inline Value Sets

#[values] generates a test for every combination of provided values on a single parameter:

#[rstest]
fn test_trim(#[values("hello", " hello", "hello ", " hello ")] input: &str) {
    let result = input.trim();
    assert_eq!(result, "hello");
}

This generates 4 tests, one per value.

Matrix Tests: Combining #[values]

When multiple parameters use #[values], rstest generates the cartesian product:

#[rstest]
fn test_padding(
    #[values(8, 16, 32)] width: usize,
    #[values("left", "right", "center")] align: &str,
) {
    let result = pad_string("hello", width, align);
    assert_eq!(result.len(), width);
}

This generates 9 tests (3 × 3). Matrix tests are powerful for testing all combinations of configuration options without writing each one manually.

#[fixture]: Reusable Test Setup

Fixtures are functions that produce values shared across tests. Declare a fixture with #[fixture]:

use rstest::fixture;

#[fixture]
fn server_url() -> String {
    "http://localhost:8080".to_string()
}

#[fixture]
fn test_user() -> User {
    User {
        id: 1,
        name: "Test User".into(),
        email: "test@example.com".into(),
    }
}

Use them by adding parameters with matching names:

#[rstest]
fn test_get_user(server_url: String, test_user: User) {
    let client = ApiClient::new(&server_url);
    let result = client.get_user(test_user.id);
    assert_eq!(result.unwrap().name, test_user.name);
}

rstest identifies fixtures by matching parameter names to #[fixture] function names. No registration step required.

Fixtures with Parameters

Fixtures can receive other fixtures as parameters:

#[fixture]
fn db_connection() -> DbConnection {
    DbConnection::open(":memory:").unwrap()
}

#[fixture]
fn seeded_db(db_connection: DbConnection) -> DbConnection {
    db_connection.execute("INSERT INTO users ...").unwrap();
    db_connection
}

#[rstest]
fn test_user_query(seeded_db: DbConnection) {
    let users = seeded_db.query("SELECT * FROM users").unwrap();
    assert_eq!(users.len(), 1);
}

Default Fixture Values

Fixtures can provide defaults that tests override:

#[fixture]
fn timeout_ms() -> u64 {
    1000
}

#[rstest]
fn test_fast_path(#[with(50)] timeout_ms: u64) {
    // Uses 50ms instead of the fixture's default 1000ms
}

Combining Cases and Fixtures

Cases and fixtures compose cleanly:

#[fixture]
fn base_user() -> User {
    User::default()
}

#[rstest]
#[case("valid@email.com", true)]
#[case("not-an-email", false)]
#[case("", false)]
fn test_email_validation(base_user: User, #[case] email: &str, #[case] expected: bool) {
    let mut user = base_user;
    user.email = email.to_string();
    assert_eq!(user.is_valid(), expected);
}

Async Tests

rstest supports async test functions with tokio::test or async-std::test:

use rstest::rstest;
use tokio::test;

#[rstest]
#[case(1, "one")]
#[case(2, "two")]
#[tokio::test]
async fn test_async_lookup(#[case] id: u32, #[case] expected: &str) {
    let result = async_lookup(id).await.unwrap();
    assert_eq!(result, expected);
}

For async fixtures:

#[fixture]
async fn async_client() -> ApiClient {
    ApiClient::connect("http://localhost:8080").await.unwrap()
}

#[rstest]
#[tokio::test]
async fn test_with_async_fixture(#[future] async_client: ApiClient) {
    let response = async_client.ping().await;
    assert!(response.is_ok());
}

The #[future] attribute tells rstest to .await the fixture before passing it to the test.

Panic Tests with rstest

Combine #[should_panic] with cases:

#[rstest]
#[case(0)]
#[case(-1)]
#[should_panic(expected = "division by zero")]
fn test_divide_panics(#[case] divisor: i32) {
    divide(10, divisor);
}

Organizing Tests with rstest

A typical rstest test module structure:

#[cfg(test)]
mod tests {
    use super::*;
    use rstest::{fixture, rstest};

    // Fixtures
    #[fixture]
    fn sample_input() -> Vec<i32> {
        vec![3, 1, 4, 1, 5, 9, 2, 6]
    }

    // Parameterized unit tests
    #[rstest]
    #[case(2, 4)]
    #[case(3, 6)]
    #[case(0, 0)]
    fn test_double(#[case] input: i32, #[case] expected: i32) {
        assert_eq!(double(input), expected);
    }

    // Tests using fixtures
    #[rstest]
    fn test_sort(sample_input: Vec<i32>) {
        let sorted = sort_vec(sample_input);
        assert!(sorted.windows(2).all(|w| w[0] <= w[1]));
    }

    // Matrix tests
    #[rstest]
    fn test_format(
        #[values("json", "yaml", "toml")] format: &str,
        #[values(true, false)] pretty: bool,
    ) {
        let output = serialize(&data(), format, pretty);
        assert!(!output.is_empty());
    }
}

When to Use rstest vs Standard Tests

Use rstest when:

  • The same logic needs testing with many input/output pairs
  • You have setup code shared across multiple tests
  • You're testing all combinations of configuration options

Use standard #[test] when:

  • You have a single specific scenario
  • Setup is trivial or inline
  • The test is documenting a specific bug or regression

Continuous Testing Beyond Unit Tests

rstest validates logic at build time. For production confidence, HelpMeTest runs plain-English test scenarios against your live service 24/7 — catching behavioral regressions that unit tests can't see.

Summary

  • #[case] creates individually-named parameterized test cases
  • #[values] generates tests for each value in a list
  • Multiple #[values] parameters produce a cartesian product of tests
  • #[fixture] defines reusable setup functions identified by name
  • Fixtures compose — they can depend on other fixtures
  • Async support via #[tokio::test] and #[future] fixtures

Read more