Foresterre's Treehouse

Foresterre's Treehouse

A blog by Martijn Gribnau

08 Mar 24

What's new in Yare 3.0.0 (A lean parameterized testing macro for Rust)

What is Yare?

Yare is a lean parameterized testing macro for Rust.

This practically means that when using #[yare::parameterized], it is easier to write a test scenario, which can be tested against multiple different inputs. Each set of inputs is a separate test case.

For example:

use yare::parameterized;

#[parameterized(
    apple = { Fruit::Apple, "apple" },
    pear = { Fruit::Pear, "pear" },
    blackberry = { Fruit::Bramble(BrambleFruit::Blackberry), "blackberry" },
)]
fn a_fruity_test(fruit: Fruit, name: &str) {
    assert_eq!(fruit.name_of(), name)
}

The above scenario will generate 3 test cases: apple, pear and blackberry, while it was only necessary to specify the scenario once.

As you might imagine, if your add more tests, the removal of duplicated test cases does not only save writing (and maintenance!) time, but makes it also easier to keep scenarios the same for a large set of inputs (especially while refactoring code, changes to test scenarios may sneak in, which should also have applied to equivalent inputs).

What's new in 3.0.0

Custom test macro (e.g. tokio::test)

Prior to 3.0.0, Yare would always generate test cases with the Rust built in #[test] attribute. While it is exceptionally useful to have this macro built in, at times you may want to use a different macro because the built-in one doesn't support a feature you need.

A common example is the tokio::test macro, when using the tokio asynchronous runtime. While you could create your own Runtime and spawn futures onto this runtime for your test cases, it is perhaps not as elegant as using the tokio::test macro.

With this use case in mind, Yare can now be used with user specified test macro's. If none is specified, the Rust built-in #[test] will be used.

Example

use yare::parameterized;

#[parameterized(
    zero_wait = { 0, 0 },
    show_paused = { 500, 0 },
)]
#[test_macro(tokio::test(start_paused = true))]
async fn test(wait: u64, time_elapsed: u128) {
    let start = std::time::Instant::now();
    tokio::time::sleep(tokio::time::Duration::from_millis(wait)).await;

    assert_eq!(time_elapsed, start.elapsed().as_millis());
}

// to use `start_paused = true`, enable the test-util feature for your tokio dependency
// example inspired by: https://tokio.rs/tokio/topics/testing

How does it work?

To make this work, the #[parameterized(...)] attribute in Yare parses all attributes placed after it (all attributes must be placed on top of the test function):

use syn::parse::{Parse, ParseStream, Result};

enum Attribute {
    /// A regular attribute, which isn't named "test_macro"
    /// NB: Attribute and syn::Attribute are not the same!
    Normal(syn::Attribute),
    // An attribute named "test_macro"
    TestMacro(syn::Meta),
}

pub struct TestFn {
    attributes: Vec<Attribute>,
    fun: syn::ItemFn,
}

impl Parse for TestFn {
    fn parse(input: ParseStream) -> Result<Self> {
        Ok(TestFn {
            attributes: input
                .call(syn::Attribute::parse_outer)?
                .into_iter()
                .map(|attr| {
                    if attr.path().is_ident("test_macro") {
                        attr.parse_args::<syn::Meta>().map(Attribute::TestMacro)
                    } else {
                        Ok(Attribute::Normal(attr))
                    }
                })
                .collect::<Result<Vec<_>>>()?,
            fun: input.parse()?,
        })
    }
}

So for the following Rust source code:

#[parameterized(
    red = { FunctionColor::Red },
    blue = { FunctionColor::Blue },
)]
#[test_macro(function_color::test_macro)]
#[should_panic]
fn test(color: FunctionColor) {
    // ...
}

We end up with 1 test_macro attribute and 1 "normal" attribute (i.e. not test_macro):

vec![
    Attribute::TestMacro(syn::Meta::parse("test_macro(function_color::test_macro)")), // hypothetically, if a &str would be a syn::ParseStream
    Attribute::Normal(syn::Attribute::parse("#[should_panic]")),
];

In the code generation phase, we can obtain these separately from TestFn:

impl TestFn {
    pub fn attributes(&self) -> Vec<syn::Attribute> {
        self
            .attributes
            .iter()
            .filter_map(Attribute::to_normal)
            .collect()
    }

    pub fn test_macro_attribute(&self) -> syn::Meta {
        self.attributes
            .iter()
            .find_map(Attribute::to_test_macro) // NB: We elsewhere asserted that there's at most one of these. 
            .unwrap_or_else(|| {
                // A definition for the default #[test] macro
                syn::Meta::Path(syn::Path::from(syn::Ident::new(
                    "test",
                    proc_macro2::Span::call_site(),
                )))
            })
    }
}

And finally during generation itself:

impl TestCase {
    pub fn to_token_stream(&self, test_fn: &TestFn) -> Result<proc_macro2::TokenStream> {
        let test_meta = test_fn.test_macro_attribute();
        let attributes = test_fn.attributes();
        // Many other things omitted...
        
        Ok(quote::quote! {
            #[#test_meta]   // <-- Our custom macro attribute, or #[test] if none was specified
            #(#attributes)* // <-- The "normal" attributes, reproduced
            #visibility #constness #asyncness #unsafety #abi fn #identifier() #return_type {
                #bindings
                #body
            }
        })
    }
}

When the code generation phase of the macro has been completed, the parameterized test function will have been substituted by two separate test functions:

#[function_color::test_macro]
#[should_panic]
fn red() {
    // ...
}

#[function_color::test_macro]
#[should_panic]
fn blue() {
    // ...
}

Gotchas to be aware of

The #[test_macro(...)] attribute must be placed after #[parameterized]

Like all macro's, yare::parameterized can only parse the available scope. Since yare::parameterized is supposed to be placed on top of functions, we can access our own attribute (which we use to parse the test case identifier and arguments for test cases) and the function underneath (used to specify the parameters and test scenario in the function body).

While yare::parameterized does have access to attributes placed after it, the ones which come before it, are inaccessible.

Subsequently, yare::parameterized can only recognize placements of #[test_macro(...)] which come after it.

So, the following is ok:

use yare::parameterized;

#[parameterized(
    wow = { "wow!" },
    whew = { "whew!" },
)]
#[test_macro(tokio::test)]
async fn test(sample: &str) {
    // ...
}

While this doesn't work:

use yare::parameterized;

#[test_macro(tokio::test)]
#[parameterized(
    wow = { "wow!" },
    whew = { "whew!" },
)]
async fn test(sample: &str) {
    // ...
}

One #[test_macro(...)] per parameterized test function

Yare currently accepts one #[test_macro(...)] for a parameterized test function. The following is not allowed and will return an error:

use yare::parameterized;

#[parameterized(
    wow = { "wow!" },
    whew = { "whew!" },
)]
#[test_macro(tokio::test)]
#[test_macro(tokio::test(start_paused = true))]
async fn test(sample: &str) {
    // ...
}

Returned error:

error: Expected at most 1 #[test_macro(...)] attribute, but 2 were given
--> tests/fail/multiple_test_macro_attributes.rs:8:14
  |
8 | #[test_macro(tokio::test(start_paused = true))]
  |              ^^^^^

The reason is that it's unclear what should happen when multiple #[test_macro(...)] attributes are present. Should the first one be used by #[parameterized(...)], and subsequent be left in place? Or would that just be confusing, if only because the test author will need to keep track of which macro uses which attributes.

Renaming attributes

Yare's parameterized attribute can be used with a different name if you would like, by rebinding the target import part under a local name:

use yare::parameterized as test_macro_for_twitchcraft_and_lizardry; // <-- Rebinding!

#[test_macro_for_twitchcraft_and_lizardry(                             <-- Usage
    gryffinroar = { "Gryffinroar" },
    hufflefluff = { "Hufflefluff" },
    ravenpaw = { "Ravenpaw" },
    slytherfin = { "Slytherfin" },
)]
fn houses(name: &str) {
    // ...
}

However, since the #[test_macro(...)] is parsed by yare::parameterized, it cannot be renamed.

Extended function qualifier support

Previously, when Yare was still written to support mostly just the built-in #[test] macro, it wasn't so useful to support the function qualifiers: const, async, unsafe and extern. Firstly, because half of these aren't even supported by #[test]. For the ones that are, namely const and extern, the I deemed the usefulness to be limited. For example: with const you can't use the commonly used assert_eq! since the PartialEq trait is not marked as const. And regarding extern, I've never seen anyone call unit test functions over FFI (but if you do, I would like to know, it does sound fun 😅).

However, with custom test macro's, you want at least support for async and maybe for unsafe. Adding the other two is hardly more work, so I also added const and extern for completeness.

NB: when specifying one ore more qualifiers in the function definition of your test function, the underlying test macro (whether #[test] or a custom macro #[test_macro(x)]) must also support the specified qualifiers.

Example

use yare::parameterized;

// NB: The underlying test macro also must support these qualifiers. For example, the default `#[test]` doesn't support async and unsafe.

#[parameterized(
    purple = { &[128, 0, 128] },
    orange = { &[255, 127, 0] },
)]
const extern "C" fn has_reds(streamed_color: &[u8]) {
    assert!(streamed_color.first().is_some());
}

Ideas, feedback and bug reports for Yare

Ideas, feedback and bug reports are most welcome. Feel free to open an issue on GitHub.

Discuss this post

Discuss on Reddit.