Question
How can I write parameterized, data-driven tests in Rust, similar to JUnit's Parameterized runner in Java?
For example, this Java test runs the same Fibonacci assertion for every (input, expected) pair:
@RunWith(Parameterized.class)
public class FibonacciTest {
@Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][] {
{ 0, 0 }, { 1, 1 }, { 2, 1 }, { 3, 2 },
{ 4, 3 }, { 5, 5 }, { 6, 8 }
});
}
private int input;
private int expected;
public FibonacciTest(int input, int expected) {
this.input = input;
this.expected = expected;
}
@Test
public void test() {
assertEquals(expected, Fibonacci.compute(input));
}
}
A normal Rust test works:
#[test]
fn it_works() {
assert!(true);
}
How can I execute a Rust test for many parameters and clearly identify which input succeeds or fails? The test data should also be flexible enough to come from a file or from every file in a directory, rather than only from hard-coded values.
Short Answer
Rust's built-in test framework can run a table of cases inside one #[test] function. For independently reported cases with fixed, compile-time-known values, use a parameterized-test crate such as rstest. For inputs discovered from files or directories at test time, iterate over them in a normal test and include the file name or input in assertion messages.
Concept
Rust does not have a built-in equivalent of JUnit's parameterized test runner that dynamically registers a separate test for every runtime value. A Rust #[test] function is normally discovered before it runs, so Cargo knows its name and can report one pass or failure for that function.
The simplest data-driven approach is to store test cases in a collection and loop over them inside one test. This is ideal when cases are loaded dynamically, such as reading fixture files from a directory.
#[test]
fn fibonacci_matches_known_values() {
let cases = [(0, 0), (1, 1), (2, 1), (3, 2), (4, 3)];
for (input, expected) in cases {
assert_eq!(fibonacci(input), expected, "failed for input {input}");
}
}
This test is one Cargo test containing multiple assertions. If an assertion fails, the message identifies the failing case.
When the inputs are known in source code and you want each case to appear as a separately named test in cargo test output, use a crate such as rstest. It generates individual test functions during compilation.
The distinction matters: runtime data can be flexible, but the built-in test runner cannot give every newly discovered runtime input its own independently registered test name.
Mental Model
Think of a normal Rust test as one checklist sheet.
- A loop inside a test is one sheet with many checklist rows. Cargo marks the whole sheet as passed or failed, while your assertion message tells you which row failed.
- A generated parameterized test is a stack of separate sheets prepared before the test run. Cargo can report each sheet independently because their cases were known when the code was compiled.
- A directory scan happens only after the test starts. It is like opening a folder during the inspection, so the test runner did not know beforehand how many sheets it would need.
Syntax and Examples
A table-driven test uses a collection of input and expected-output pairs.
fn fibonacci(n: u32) -> u32 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
#[cfg(test)]
mod tests {
use super::fibonacci;
#[test]
fn fibonacci_cases() {
let cases = [
(0, 0),
(1, 1),
(2, 1),
(3, 2),
(4, 3),
(5, 5),
(6, 8),
];
for (input, expected) in cases {
let actual = fibonacci(input);
assert_eq!(actual, expected, "unexpected result for input {input}");
}
}
}
assert_eq! compares the actual and expected values. Its optional third argument is a custom failure message. When a case fails, Rust also prints the differing values.
For source-code cases that should be shown separately, add rstest as a development dependency:
[dev-dependencies]
rstest = "0.25"
Then write cases with #[case]:
#[cfg(test)]
mod tests {
use rstest::rstest;
fn fibonacci(n: u32) -> u32 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
#[rstest]
#[case(0, 0)]
#[case(1, 1)]
#[case(2, 1)]
#[case(6, 8)]
fn fibonacci_cases(#[case] input: u32, #[case] expected: u32) {
assert_eq!(fibonacci(input), expected);
}
}
rstest expands these attributes into tests during compilation. It is useful for concise, fixed test data, not for cases discovered by reading a directory during the test run.
Step by Step Execution
Consider this test:
#[test]
fn doubles_each_value() {
let cases = [(1, 2), (4, 8), (10, 20)];
for (input, expected) in cases {
let actual = input * 2;
assert_eq!(actual, expected, "case input={input}");
}
}
Execution proceeds as follows:
- Rust starts the single test named
doubles_each_value. casesstores three tuples. Each tuple has an input and its expected result.- The first loop iteration assigns
input = 1andexpected = 2. - The test calculates
actual = 2, thenassert_eq!succeeds. - The next iterations test
(4, 8)and(10, 20)the same way. - If all assertions succeed, Cargo reports one passed test.
- If, for example, the expected value for
10were21, the assertion would fail and the custom message would showcase input=10. The loop stops at that first panic, and Cargo reports the one test as failed.
A loop is therefore straightforward and flexible, but it does not continue automatically after a failing assertion.
Real World Use Cases
Parameterized or table-driven tests are common when the same behavior must be checked against many inputs:
- Parsing: map text inputs to parsed values, or verify invalid input returns an error.
- Validation: test accepted and rejected email addresses, passwords, IDs, or request fields.
- Business rules: test tax brackets, discounts, shipping thresholds, and permission combinations.
- API transformations: verify JSON payloads are converted into the expected internal types.
- Regression fixtures: keep a directory of input files that previously exposed bugs and run every file through a parser.
- Command-line tools: test each fixture file and compare the produced output with a corresponding expected file.
For file-based fixtures, a test usually loops through paths. Sorting paths makes the test order deterministic, which makes failures easier to reproduce.
Real Codebase Usage
In real Rust projects, choose the pattern based on where test data comes from.
Fixed cases in code
Use an array or rstest for small, readable sets of cases.
#[test]
fn recognizes_valid_status_codes() {
for code in [200, 201, 204] {
assert!(is_success(code), "expected {code} to be successful");
}
}
Fixture files discovered at runtime
Use std::fs::read_dir, handle I/O errors with expect or ?, and include the path in failures.
use std::fs;
use std::path::PathBuf;
#[test]
fn parses_every_json_fixture() -> Result<(), Box<dyn std::error::Error>> {
let mut paths: Vec<PathBuf> = fs::read_dir("tests/fixtures")?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<Result<_, _>>()?;
paths.sort();
for path in paths {
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let source = fs::read_to_string(&path)?;
let result = parse_document(&source);
assert!(result.is_ok(), "could not parse fixture: {}", path.display());
}
Ok(())
}
Helpful patterns
- Give every case a label: use a struct such as
Case { name, input, expected }when tuples become unclear. - Use guard clauses: skip files with irrelevant extensions early.
- Return
Resultfrom I/O-heavy tests: this keeps file-reading failures clear without repeatedunwrap()calls. - Keep fixtures under
tests/fixtures: integration and unit tests can use stable project-relative paths when run by Cargo. - Separate categories: one test for valid files and one for invalid files often makes expected failures easier to understand.
Common Mistakes
Expecting a loop to create separate Cargo tests
#[test]
fn cases() {
for input in [1, 2, 3] {
assert!(input > 0);
}
}
This is one test, not three. Use separate #[test] functions or rstest when each fixed case needs independent reporting.
Omitting context from assertions
for (input, expected) in cases {
assert_eq!(calculate(input), expected);
}
Rust shows actual and expected values, but a descriptive message is invaluable for complex input.
for (input, expected) in cases {
assert_eq!(calculate(input), expected, "calculation failed for {input:?}");
}
Using assert! when comparing values
assert!(actual == expected);
Prefer assert_eq!(actual, expected). Its failure output clearly labels the left and right values.
Assuming every case runs after one failure
assert_eq! panics on failure, so the loop stops. If you need to collect multiple failures, record errors manually and assert after the loop. In most tests, stopping at the first failure is simpler and sufficient.
Relying on directory iteration order
File system order is not guaranteed. Collect and sort file paths before testing them.
Putting an external test crate in regular dependencies
A parameterized-test framework is normally needed only for tests. Place it under [dev-dependencies] in Cargo.toml.
Comparisons
| Approach | Data can come from files at runtime? | Separate entries in cargo test output? | Best use |
|---|---|---|---|
Loop inside #[test] | Yes | No; it is one test | Dynamic fixtures, directory scans, simple tables |
Multiple #[test] functions | No automatic discovery | Yes | A few important named scenarios |
rstest parameterized cases | Cases are declared in source | Yes | Many fixed, readable cases |
| Custom test harness | Possible | Possible | Specialized tooling; rarely needed for ordinary application tests |
#[test] is Rust's built-in test attribute. It works without dependencies and is the best default for dynamic input.
rstest reduces repeated test code and gives fixed cases more granular output, but its #[case(...)] values are part of the compiled source rather than a directory scanned during execution.
Cheat Sheet
- Mark a Rust test with
#[test]. - Put test-only code in a
#[cfg(test)] mod testsmodule. - Use a loop for flexible, runtime-loaded cases.
for (input, expected) in cases {
assert_eq!(function(input), expected, "input: {input:?}");
}
- Use
assert_eq!(actual, expected)for value comparisons. - Add a custom assertion message to identify a case.
- A loop inside one test produces one Cargo test result.
assert!,assert_eq!, andassert_ne!stop the current test at the first failure.- Sort paths from
read_dirfor repeatable fixture order. - Return
Result<(), Box<dyn std::error::Error>>from tests that perform file I/O. - Use
rstestin[dev-dependencies]when fixed cases should be independently reported.
[dev-dependencies]
rstest = "0.25"
FAQ
Does Rust have built-in parameterized tests?
Rust's standard #[test] framework does not provide a built-in attribute for parameter lists. Use a loop for table-driven tests or a crate such as rstest for generated fixed cases.
Can I read parameterized test cases from a file in Rust?
Yes. Read the file in a normal #[test], parse its contents, and loop over the resulting cases. This is a good reason to use a loop instead of compile-time-generated cases.
Can Rust show each loop iteration as a separate test?
Not with the standard test harness when the iterations are discovered at runtime. Cargo sees the enclosing test function as one test. Include a useful case label in assertion messages.
Which assertion should I use for expected values?
Use assert_eq!(actual, expected). It produces better failure output than assert!(actual == expected).
Why does my parameter loop stop after the first failed case?
Standard assertion macros panic when they fail. A panic ends that test function. To inspect further cases, fix the first failure or collect errors manually before making one final assertion.
Should I use rstest or a loop?
Use rstest when cases are fixed in source code and individual reporting is valuable. Use a loop when cases are loaded, generated, or discovered at runtime.
Where should fixture files be stored?
A common convention is tests/fixtures/. Keep fixture data version-controlled and use descriptive file names so failure messages are meaningful.
Mini Project
Description
Build a file-based test suite for a small text parser. The test reads every .txt fixture in a directory, parses a number from each file, and checks it against the expected value encoded in the file name. This demonstrates why a loop-based test is practical when test inputs are discovered at runtime.
Goal
Test every matching fixture file and produce a failure message that identifies the specific file that failed.
Requirements
Create a tests/fixtures/numbers directory with sample .txt files.
Name each fixture as <expected>.txt, such as 42.txt.
Read every .txt file in the directory.
Sort file paths before running the cases.
Report the fixture path when parsing or comparison fails.
Keep learning
Related questions
Absolute Values in Rust: Using the abs() Method
Learn how to calculate absolute values in modern Rust with abs(), handle signed integer overflow, and choose safer alternatives.
Accepting Iterators in Rust Functions with impl Iterator
Learn how to write Rust functions that accept HashMap iterators using generic bounds or impl Iterator, avoiding trait object and lifetime errors.
Access Enum Variant Values in Rust with Pattern Matching
Learn how to extract and print values stored in Rust enum variants using match and if let pattern matching, with practical examples.