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 |