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.
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 ... okCases 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