Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing with trybuild

In the proc-macro2 section you wrote a unit test that called your code-generation function directly and asserted on the tokens it produced — no separate crate, no real #[derive]. That’s the fastest way to check your logic, and you can do the same for the FieldNames macro (using parse_quote! now that you know it):

#![allow(unused)]
fn main() {
#[test]
fn generates_field_names() {
    let input: syn::DeriveInput = syn::parse_quote! { struct Foo { x: i32 } };
    let output = field_names_impl(&input).to_string();
    assert!(output.contains("field_names"));
}
}

This is useful, but a unit test like this has two blind spots.

Blind spot 1: valid tokens ≠ working code

You can strengthen the test by asserting that the output parses — for example with syn::parse2. But parsing only proves the tokens are syntactically well-formed; it does not prove they compile and run. Imagine the macro had a typo and generated the method name feild_names:

#![allow(unused)]
fn main() {
quote! {
    impl #name {
        pub fn feild_names() -> &'static [&'static str] { &[ #(#field_strings),* ] }
    }
}
}

This still parses as a perfectly valid impl block, so a syn::parse2 assertion passes. But every caller that writes Foo::field_names() now gets a compile error. A unit test won’t catch this — only actually compiling the generated code will.

Blind spot 2: you can’t see the error the user sees

When the macro is misused — say, on an enum — field_names_impl panics. A unit test can confirm that it panics:

#![allow(unused)]
fn main() {
#[test]
#[should_panic]
fn rejects_enums() {
    let input: syn::DeriveInput = syn::parse_quote! { enum E { A, B } };
    field_names_impl(&input);
}
}

But this tells you nothing about what the user experiences at their #[derive(FieldNames)] call site: which message they get, and where the compiler points. Right now that message is just a raw panic — from chapter 4 onward you’ll put real effort into making these diagnostics helpful, and you’ll need a way to see and lock in exactly what the user gets.

trybuild fills both gaps

trybuild is a test harness that compiles standalone Rust files — as if a user wrote them — and checks whether they succeed or fail with the expected error messages. It catches both blind spots: it compiles the generated code for real, and it snapshots the exact diagnostic a misuse produces.

How it works

A trybuild test is a regular #[test] function that points at a directory of .rs files:

#![allow(unused)]
fn main() {
#[test]
fn tests() {
    let t = trybuild::TestCases::new();
    t.pass("examples/*.rs");
    t.compile_fail("tests/fail/*.rs");
}
}
  • pass() — each file must compile and run successfully.
  • compile_fail() — each file must fail to compile, and the error output must match a corresponding .stderr file.

The conventional layout puts passing cases in tests/pass/, but in this workshop they live in examples/ instead — the next section explains why.

Test file structure

Each test file is a self-contained Rust program. For testing a derive macro, a passing test might look like:

// examples/basic_struct.rs
use my_macros::MyDerive;

#[derive(MyDerive)]
struct Foo {
    x: i32,
}

fn main() {
    // Verify the generated code works.
    assert_eq!(Foo::field_names(), &["x"]);
}

Note the fn main() — each test file is compiled as its own binary.

Inspecting the output with cargo expand

Once a passing case compiles, you’ll often want to see what your macro actually expanded to — cargo expand is the tool for that. But cargo expand can only target things Cargo knows how to build: a library, a binary, a test, or an example. A file under tests/pass/ is none of those — trybuild compiles it itself, in a throwaway crate, so there’s no Cargo target to point cargo expand at.

That’s why, throughout this workshop, the passing cases live in examples/ rather than the conventional tests/pass/. trybuild doesn’t care where the files sit — t.pass("examples/*.rs") globs them just the same — but now each passing case is also a real example target. So you can run:

cargo expand --example basic_struct

to see exactly what your macro generated for that input (and cargo run --example basic_struct runs it, fn main() and all).

The compile-fail cases stay in tests/fail/: they’re meant not to compile, so they can’t be examples — and there’s nothing to expand anyway.

Testing compilation failures

For compile_fail tests, trybuild compares the compiler’s error output against a .stderr file with the same name:

examples/
└── basic_struct.rs        # a passing case — also `cargo expand --example basic_struct`
tests/
└── fail/
    ├── not_a_struct.rs
    └── not_a_struct.stderr

On the first run, if no .stderr file exists, trybuild shows the actual compiler output and fails the test. You can set the environment variable TRYBUILD=overwrite to have it write the .stderr files for you, then review them with git diff to make sure they match your expectations.

Why trybuild?

  • It closes blind spot 1: pass tests compile and run the generated code, so a typo like feild_names fails the test instead of slipping through.
  • It closes blind spot 2: compile_fail tests snapshot the exact diagnostic the user sees. Once you start improving error messages in chapter 4, the .stderr file is what proves those messages are actually helpful — and it catches regressions if a syn::Error span later changes.
  • It complements proc-macro2 unit tests rather than replacing them: unit tests verify your logic fast and in isolation, trybuild verifies the end result from the user’s perspective.

We’ll use trybuild throughout the rest of this workshop to verify both successful compilation and expected error messages.

Exercise

The exercise for this section is located in 03_basics/09_trybuild

Write trybuild tests for the FieldNames derive macro you built in the quote exercise. Add a passing case in examples/ (a named struct) and a compile-fail case in tests/fail/ (using the macro on an enum). Once the passing case compiles, run cargo expand --example <name> on it to see the field_names() method your macro generated.