How to use test-case crate for parameterized tests

Use the test-case crate to run a single test function with multiple argument sets via the #[test_case] macro.

When test functions start looking identical

You wrote a function that validates user input. The first test passes. You add a second test for a different edge case. It passes. By the fifth test, your file looks like a wall of nearly identical functions. You change the input, you change the expected output, the assertion logic stays the same. Copy-pasting test functions creates technical debt faster than the code you're testing. You end up with test_valid_email, test_invalid_email, test_empty_email, and when the validation logic changes, you have to update six separate test functions and hope you didn't miss one.

Separating data from logic

Parameterized tests solve this by decoupling the test data from the test logic. You write the function once. You provide a list of inputs and expected outputs. The test runner executes the function for every row in that list. Think of it like a spreadsheet where column A is the input, column B is the expected result, and the formula in column C checks if they match. The test-case crate brings this pattern to Rust using a macro that generates the boilerplate for you.

Separate the data from the logic, and your tests become a specification you can scan, not a maze you have to navigate.

Minimal example

Add the crate to your Cargo.toml as a development dependency. Tests are tools for building the project, not part of the shipped binary.

cargo add test-case --dev

Import the macro and decorate your test function. The syntax inside the attribute mirrors the function signature. Inputs come first, followed by => and the expected return value.

use test_case::test_case;

/// Adds two integers and returns the sum.
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// The macro generates a separate test function for each attribute.
// The syntax is `inputs => expected_output`.
// The string after the semicolon is the test name suffix.
#[test_case(1, 2 => 3; "basic addition")]
#[test_case(-5, 5 => 0; "cancellation")]
#[test_case(0, 0 => 0; "identity")]
fn test_add(a: i32, b: i32) -> i32 {
    // The function body runs for every case.
    // The macro asserts the return value matches the expected output.
    add(a, b)
}

Write the function once, list the cases, and let the macro handle the repetition.

What happens at compile time

The #[test_case] macro runs during compilation. It scans the attributes above the function and emits new code. If you have three attributes, the macro generates three functions: test_add_basic_addition, test_add_cancellation, and test_add_identity. Each generated function calls your original test_add with the specific arguments and asserts the result matches the expected value.

When you run cargo test, the test runner discovers these generated functions and executes them individually. If one fails, the output shows the specific suffix, so you know exactly which case broke. The macro hides the repetition but exposes the failure. You get the signal of a single test function with the granularity of many.

Realistic patterns with Result and structs

Real code rarely returns simple integers. Functions return Result, Option, or complex structs. The crate handles these types naturally because the macro relies on PartialEq for assertions. If your type implements PartialEq, you can use the => syntax directly.

use test_case::test_case;

/// Parses a temperature string into Celsius.
fn parse_temp(input: &str) -> Result<f64, &'static str> {
    input
        .trim()
        .parse::<f64>()
        .map_err(|_| "Invalid number")
}

// Testing successful parses.
// Result implements PartialEq, so the macro can assert Ok vs Err.
#[test_case("20.5" => Ok(20.5); "decimal")]
#[test_case("-5" => Ok(-5.0); "negative")]
#[test_case("  30  " => Ok(30.0); "whitespace")]
fn test_parse_success(input: &str) -> Result<f64, &'static str> {
    parse_temp(input)
}

// Testing failures.
// The macro compares the Err variant and the error message.
#[test_case("abc" => Err("Invalid number"); "letters")]
#[test_case("") => Err("Invalid number"); "empty")]
fn test_parse_failure(input: &str) -> Result<f64, &'static str> {
    parse_temp(input)
}

You can also pass structs as inputs. This is useful when your function takes a configuration object or a domain model. Derive PartialEq if you need to assert the struct as output, or just pass it as input if the function returns a scalar.

use test_case::test_case;

/// Represents a user configuration.
#[derive(Debug, PartialEq)]
struct Config {
    timeout: u32,
    retries: u32,
}

/// Validates configuration constraints.
fn validate_config(c: &Config) -> bool {
    c.timeout > 0 && c.retries < 10
}

// Struct literals work directly in the attribute.
// The macro infers the type from the function signature.
#[test_case(Config { timeout: 1, retries: 5 } => true; "valid config")]
#[test_case(Config { timeout: 0, retries: 5 } => false; "zero timeout")]
#[test_case(Config { timeout: 10, retries: 10 } => false; "max retries")]
fn test_config(c: &Config) -> bool {
    validate_config(c)
}

Parameterized tests shine when the edge cases multiply. List them all, and you catch the regression before it ships.

Pitfalls and compiler errors

The macro expands to code that calls your function. If the types in the attribute don't match the function signature, the compiler rejects the expansion. You'll see E0308 (mismatched types) if you pass a String where the function expects &str, or if the expected output type differs from the return type. The macro also requires the function to return a value that implements PartialEq if you use the => expected syntax. If your function returns (), you can only use the attribute to pass arguments, not assert a result. In that case, write the assertions manually inside the function body.

Convention aside: always add the suffix string after the semicolon. #[test_case(1, 2 => 3; "one plus two")]. Without the suffix, the test names become generic and hard to read in failure output. The suffix makes the test report actionable. Another convention: keep test-case in dev-dependencies. Running cargo add test-case --dev ensures the crate doesn't bloat your production binary.

Always name your cases. A failed test without a name is a bug hunt without a map.

When to use test-case versus alternatives

Use test-case when you have a function with clear inputs and outputs and you want to verify multiple specific scenarios without duplicating the assertion logic. Use doc tests when you need the example to serve as documentation for the public API and want to ensure the docs stay accurate. Use proptest when you want to generate random inputs and check invariants rather than verifying fixed cases. Use #[should_panic] alone when you only have one case where a panic is expected and parameterization adds no value.

Pick the tool that matches the shape of your data. Specific cases get test-case. Random chaos gets proptest.

Where to go next