I present here a macro supported approach for tests in Rust.

Imagine the system you write in Rust deals with SaaS contracts.

Customers can upgrade them, change the period on which usage is billed, maybe align cycles with the end of the free trials, etc.

As you imagine, there are many parameters involved, and many sanity checks.

When the system is complex, you may need thousands of tests.

Yet you want your tests to be readable, and you want your non-programmer colleagues to understand them and maybe even discuss them.

For example you want this:

#[test]
fn upgrade_natural_months_to_weeks() {
    let mut first_contract = new_contract!(
        start: 2024/05/16@23
        cycle: Month
        align: Natural
    );
    let second_contract = upgrade!(
        on 2024/08/24@18
        from first_contract
        cycle: Week
    );

    let mut periods = first_contract.periods().unwrap();
    check!(periods:
        2024/05/16@23 to 2024/06/01
        2024/06/01 to 2024/07/01
        2024/07/01 to 2024/08/01
        2024/08/01 to 2024/08/24@18
    );
    assert!(periods.next().is_none());

    assert_eq!(second_contract.cycle, Cycle::Week);
    let mut periods = second_contract.periods().unwrap();
    check!(periods:
        2024/08/24@18 to 2024/08/26 // to Monday
        2024/08/26 to 2024/09/02
        2024/09/02 to 2024/09/09
    );
}

In this test we check that billing periods of a contract are as expected on contract upgrade and with given settings.

This easy to follow test leverages macros forming a kind of Domain-Specific Language.

Let's explain more.

Focus the semantics

The test above is lying. new_contract! doesn't create a contract, because a contract is a complex thing and, in this module, we only want to test how contract periods are generated and modified.

So new_contract! generates an object, called PeriodRule, which is used when dealing with contracts but not only, and not the whole contract.

In another module, new_contract! can for example generate the tax related settings.

upgrade! also generates a PeriodRule, after having applied a process similar to the one of a contract upgrade, but only concerned about time stuff.

Macros defined in this module for this module are named in such a way that the business logic of the scenario is obvious.

Forget about the other paths

A Rust program, or any serious program, has to deal with many possible paths, with all the possible contexts and all the ways things will go wrong.

But in your test, you want to check it goes as planned, and it's OK to panic if it doesn't.

Macros help you hide all the panic, all the checks, keeping only the normal path visible.

Hide unnecessary stuff

Dealing with a contract, or time, involves many settings. To show everything would be losing both the test writer and the reviewer.

A test module is a small focused world, in which you don't want to repeat all the constant settings in which tests happen. Your test macros help hiding that.

First you can hide a lot of stuff as default values in our test dedicated macros.

You can also make your macros accept optional arguments, because some tests of the module need more arguments. For example you may notice that sometimes the date is precise to the hour, but most often we don't care.

You can also make the macros variadic: accept a variable number of arguments. That's the case for the whole check! macro.

Declarative macros

Declarative macros are easy to write.

A macro_rules! declaration is a set of patterns, each one with the code to generate when it matches the arguments you give.

Here's a (simplified) example:

// doesn't really return a contract but a period_rule, which behaves
// the same but without more stuf to initialize
macro_rules! new_contract {
    (
        start: $y:literal/$m:literal/$d:literal@$h:literal
        cycle: $cycle:ident
        align: $align:ident
    ) => {
        PeriodRule::for_signin(d!($y/$m/$d@$h), Cycle::$cycle, CycleAlign::$align)
    };
}

Accept several forms

Here's how dates are created in this module:

// generate a date_time for a date, with optional hours and minutes
macro_rules! d {
    ($y:literal/$m:literal/$d:literal) => {
        Utc.with_ymd_and_hms($y, $m, $d, 0, 0, 0).unwrap()
    };
    ($y:literal/$m:literal/$d:literal@$h:literal) => {
        Utc.with_ymd_and_hms($y, $m, $d, $h, 0, 0).unwrap()
    };
    ($y:literal/$m:literal/$d:literal@$h:literal:$mm:literal) => {
        Utc.with_ymd_and_hms($y, $m, $d, $h, $mm, 0).unwrap()
    };
}

Depending on the need, you'll refer to a date as 2024/05/16, or 2024/05/16@23, or 2024/05/16@23:55.

It's easy to extend, too.

There are unwrap() because a panic when the date literals are invalid is the right thing to do. The macro doesn't accept expressions but only literal: this prevents the macro from being used in dynamic context where a panic would be totally unacceptable.

Macro composition: Call macros in macros

As you may have noticed, the new_contract! expansion calls the d! macro.

Nothing prevents you from calling macros in macros, making each of them simpler.

Here's another macro calling the d! one.

// generate a period
// When the hour is ommited, it means 0h0m0s
macro_rules! p {
    ($y1:literal/$m1:literal/$d1:literal$(@$h1:literal)* to $y2:literal/$m2:literal/$d2:literal) => {
        Period {
            start: d!($y1/$m1/$d1$(@$h1)*),
            end: d!($y2/$m2/$d2),
        }
    };
}

If you look closely, you see it uses the repetition syntax ((@h1:literal)* to make the hour optional without resorting to 2 forms of the p pattern.

Accept a variable number of arguments, simply

Tests often need to be repeated on a variable number of value sets.

You don't have to repeat the macro calls, you may just accept them in the macro with repetitions.

For example, to do this:

let mut periods = first_contract.periods().unwrap();
check!(periods:
    2024/05/16@23 to 2024/06/01
    2024/06/01 to 2024/07/01
    2024/07/01 to 2024/08/01
    2024/08/01 to 2024/08/24@18
);

You may use this macro_rules!:

// check that a sequence returned by the iterator is made of the expected periods
macro_rules! check {
    (
        $g:ident:
        $(
            $y1:literal/$m1:literal/$d1:literal$(@$h1:literal)* to $y2:literal/$m2:literal/$d2:literal$(@$h2:literal)*
        )*
    ) => {
        $(
            assert_eq!(
                $g.next().unwrap(),
                Period {
                    start: d!($y1/$m1/$d1$(@$h1)*),
                    end: d!($y2/$m2/$d2$(@$h2)*),
                }
            );
        )*
    };
}

As a conclusion

If you have 100 tedious lines of setup between two asserts, or if you have many checks, or settings, you lose the plot, it's very hard to read and maintain your test, to validate them, and chances are that you won't have as many distinct tests than what would be needed.

That's why it's useful, when you have a set of scenarios on a consistent topic, to find ways to make them simpler, focused, more readable.

In Rust, declarative macros are a convenient tool for that.