Declarative and Procedural Macros in Depth

Macro Testing

Macro testing verifies that a declarative or procedural macro expands to valid, hygienic Rust code on supported inputs and emits clear, accurately spanned compile-time diagnostics on invalid inputs.

The Four Layers of Macro Testing

1. Behavioral Unit & Integration Tests (`cargo test`)
     - Invoke the macro in weird hygiene contexts (no imports in scope, shadowed `std` types)

2. Pure AST Expansion Snapshot Tests (`proc-macro2` + `quote` in `#[test]`)
     - Test the core `fn expand(TokenStream) -> TokenStream` function directly in unit tests!

3. Compile-Fail UI Tests (`trybuild` crate)
     - Compile small `.rs` test files that SHOULD fail and compare the exact `rustc` stderr output!

4. Expansion Inspection (`cargo expand` / `rustc -Zunpretty=expanded`)
     - Visually inspect the generated Rust code after all macros are expanded

Layer 1: Testing Hygiene in a Clean-Room Module (#![no_implicit_prelude])

A common bug in exported macros (#[macro_export]) is assuming Option, Some, None, Result, Ok, Err, Vec, or String are in scope or unshadowed at the call site. Testing your macro inside a submodule that shadows Option/Result—or uses #[no_implicit_prelude]—proves the macro qualifies all standard paths (::core::option::Option::Some, $crate::...):

#[macro_export]
macro_rules! checked_div {
    ($num:expr, $den:expr) => {{
        let d = $den;
        if d == 0 {
            ::core::option::Option::None
        } else {
            ::core::option::Option::Some($num / d)
        }
    }};
}

// Hostile hygiene test module where `Option` and `None` are shadowed by custom types:
mod hostile_caller_scope {
    #[allow(dead_code)]
    struct Option;
    #[allow(dead_code)]
    struct None;

    pub fn run_test() -> ::core::option::Option<i32> {
        // Succeeds because `checked_div!` uses fully qualified `::core::option::Option`:
        checked_div!(100, 4)
    }
}

fn main() {
    println!("hostile scope result: {:?}", hostile_caller_scope::run_test());
}
hostile scope result: Some(25)

Layer 2: Unit-Testing Procedural Macro Logic via proc-macro2

Because proc_macro::TokenStream panics if called outside rustc's proc-macro server, always write your macro's transformation logic against proc_macro2::TokenStream:

// Inside your proc-macro crate:
// Entry point (`proc_macro::TokenStream` -> `proc_macro2::TokenStream` -> `proc_macro::TokenStream`):
/*
#[proc_macro_derive(MyDerive)]
pub fn my_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    expand_my_derive(proc_macro2::TokenStream::from(input)).into()
}

// Pure `proc_macro2` function that can be tested directly with `cargo test`!
fn expand_my_derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { ... }

#[cfg(test)]
mod tests {
    use super::*;
    use quote::quote;

    #[test]
    fn generates_expected_impl() {
        let input = quote! { struct Foo { x: i32 } };
        let output = expand_my_derive(input);
        assert!(output.to_string().contains("impl Describe for Foo"));
    }
}
*/
fn main() {
    println!("Separating proc_macro2 logic from the proc_macro entry point enables fast unit tests.");
}
Separating proc_macro2 logic from the proc_macro entry point enables fast unit tests.

Layer 3 & 4: cargo expand and trybuild

Tool Command / Usage Purpose
cargo expand cargo expand module::item Pretty-prints the expanded Rust source code of a module or function using rustfmt
trybuild let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); Compiles test files that are expected to fail and diffs rustc's exact error output against .stderr snapshot files