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