Skip to main content

Parametrised tests in Rust with named parameters

I’ve been revisiting some of my small Rust utilities this week, and I wanted to improve the test suite. There’s a lot of repetition, and I wanted to switch to parametrised tests, a pattern I use a lot in Python and Go. (These are also called “table-driven tests”.)

Parameterised tests allow you to write the test logic once, then run it with many different inputs and expected results. This reduces repetition, highlights variation, and simplifies adding new cases.

For example, in my thumbnail creator, I want to create a thumbnail with various image formats, and check the thumbnail has the correct filename and dimensions. Rather than copy-pasting test functions, I wanted to declare test cases with named fields, like this:

animated_gif: {
    img_path:          "src/tests/animated_squares.gif",
    target:            TargetDimension::MaxWidth(16),
    expected_filename: "animated_squares.mp4",
    expected_dims:     (16, 16),
},

Supporting this syntax required me to create a custom Rust macro.

Existing approaches

I found three existing approaches to writing this type of test in Rust:

  1. Use a for loop over the test cases. This is the simplest approach: define our test cases in an array, then iterate over them one-by-one.

    #[test]
    fn test_fibonacci_numbers() {
        let test_cases = [(1, 1), (6, 8), (10, 55)];
    
        for (input, expected) in test_cases {
            assert_eq!(fibonacci(input), expected);
        }
    }

    This is easy to write and understand, but Rust’s standard assertions don’t give us test case isolation. If one case fails, the test stops immediately and skips the rest.

    (Go’s testing library has t.Error, which marks a test as failed but allows it to continue running. When a test fails, you often have more information than if the test had failed immediately. I now miss this when working in other languages.)

  2. Define a macro with positional arguments. Many guides, such as over-code’s article, define custom macros with positional arguments.

    fib_test!(my_tests,
        basic: 1, 1,
        sixth: 6, 8,
        tenth: 10, 55,
    );

    Named test cases are nice, but positional arguments hurt readability if you have more than a few parameters.

  3. Use a third-party testing library. Crates like parameterized, rstest, and yare use attributes to declare test cases.

    use rstest::rstest;
    
    #[rstest]
    #[case(1, 1)]
    #[case(6, 8)]
    #[case(10, 55)]
    fn fibonacci_test(#[case] input: u32,#[case] expected: u32) {
        assert_eq!(expected, fibonacci(input))
    }

    If I was working in a larger codebase, I’d compare these libraries and pick my favourite.

    I deliberately skipped them this time, because I only use Rust for tiny personal tools, and I write it as a learning exercise. I’d rather write a basic version myself and learn something new, than use somebody else’s library and miss out on the lesson.

Writing my own macro

Since I couldn’t find a macro that gave me named parameters, I decided to write my own.

Here’s the complete macro invocation for my image thumbnailer, with three test cases:

image_thumbnail_tests! {
    animated_gif: {
        img_path:          "src/tests/animated_squares.gif",
        target:            TargetDimension::MaxWidth(16),
        expected_filename: "animated_squares.mp4",
        expected_dims:     (16, 16),
    },
    static_gif: {
        img_path:          "src/tests/yellow.gif",
        target:            TargetDimension::MaxWidth(16),
        expected_filename: "yellow.gif",
        expected_dims:     (16, 8),
    },
    jpeg: {
        img_path:          "src/tests/noise.jpg",
        target:            TargetDimension::MaxWidth(16),
        expected_filename: "noise.jpg",
        expected_dims:     (16, 32),
    },
}

The goal is to have clear, self-documented test cases. You can see what the test does and how to add a new test case without reading the body of the macro.

Here’s the macro definition, with extra whitespace to make the structure more obvious:

macro_rules! image_thumbnail_tests {
    (
        $(
            $name:ident: {
                img_path: $img_path:expr,
                target: $target:expr,
                expected_filename: $expected_filename:expr,
                expected_dims: $expected_dims:expr,
            },
        )*
    )
    =>
    {
        $(
            #[test]
            fn $name() {
                let path = PathBuf::from($img_path);
                let out_dir = test_dir();
                let thumbnail_path = create_thumbnail(&path, &out_dir, $target).unwrap();

                assert_eq!(thumbnail_path, out_dir.join($expected_filename));
                assert!(thumbnail_path.exists());
                assert_eq!(get_dimensions(&thumbnail_path), $expected_dims);
            }
        )*
    }
}

If we zoom out, the structure of a declarative macro looks like a match expression:

macro_rules! image_thumbnail_tests {
  ( matcher ) => { transcriber }
}

I think of the two halves are input/output, but they’re really called matcher/transcriber:

  1. The left-hand side (matcher). The matcher defines a pattern of Rust tokens the input code must match precisely, including commas and brackets (but ignoring whitespace).

    • $(…)* works like a regular expression, matching zero or more instances of the enclosed pattern.
    • $name:ident and $img_path:expr match a Rust identifier and expression, respectively, and bind them to metavariables (similar to named capture groups in regexes).

    I deliberately designed my pattern to resemble Rust’s struct syntax. Macro inputs don’t have to be valid Rust code, but I wanted something that would look familiar in a Rust file.

  2. The right-hand side (transcriber). The transcriber is a template for Rust code, which gets substituted in at compile time, replacing metavariables with values captured by the matcher.

    Here, the $(…)* iterates over the matches and creates a corresponding function with a #[test] attribute. This attribute is why we need to use a macro – there’s no way to dynamically generate new test functions in Rust without macros.

When compiled, the macro gets replaced with all the generated code from the transcriber, as if I’d written out the individual tests in full:

#[test]
fn animated_gif() {
    let path = PathBuf::from("src/tests/animated_squares.gif");
    let out_dir = test_dir();
    let thumbnail_path = create_thumbnail(&path, &out_dir, TargetDimension::MaxWidth(16)).unwrap();

    assert_eq!(thumbnail_path, out_dir.join("animated_squares.mp4"));
    assert!(thumbnail_path.exists());
    assert_eq!(get_dimensions(&thumbnail_path), (16, 16));
}

#[test]
fn static_gif() {
    let path = PathBuf::from("src/tests/yellow.gif");
    …

This means all of my test cases run as separate tests, so I get all the benefits of individual test functions without having to copy-paste lots of boilerplate throughout my codebase.

I’ve seen custom macros pop up in the few Rust codebases I’ve read, and it feels good to have a better understanding of how they work. I used to copy-paste the same snippets thoughtlessly from project to project, but now I know what the different brackets and asterisks mean. The next time I write a macro or read somebody else’s, I’ll know what’s going on.