Add trybuild as a dev-dependency in your Cargo.toml.

Mục tiêu:


An immense congratulations! You have officially completed the entire code generation logic for your #[derive(Builder)] procedural macro. From parsing the input struct to assembling the final TokenStream, you’ve built a powerful piece of metaprogramming. The code looks perfect, but in the world of software engineering, “looks correct” is never enough. We must prove it.

This brings us to a new, exciting phase: testing. But how does one test a program whose job is to write other programs?

The Unique Challenge of Testing Procedural Macros

Testing a procedural macro presents a different challenge than testing a regular function. A regular function, say add(a: i32, b: i32) -> i32, takes simple data as input and produces simple data as output. We can easily write a unit test like assert_eq!(add(2, 2), 4).

Our derive_builder function, however, takes a TokenStream (code) as input and produces a TokenStream (more code) as output. We could try to assert that the output TokenStream is equal to a giant, hardcoded string of the expected generated code. This approach is incredibly fragile: * A tiny, insignificant change in formatting would break the test. * A change in the implementation of quote! could alter the output slightly. * Maintaining a massive string of expected code is a nightmare.

Instead, we need to test the effect of our macro. We don’t care about the exact characters it generates; we care that the generated code compiles and behaves as expected. This is the core philosophy behind UI (User Interface) testing or compile-time testing for macros. We will write small test cases that use our macro and check if they compile successfully.

Introducing trybuild: The Gold Standard for Macro Testing

To accomplish this, we will use trybuild, the idiomatic and powerful testing harness for procedural macros in the Rust ecosystem. trybuild is designed to run the Rust compiler against a collection of test files (the “UI tests”) and report on their success or failure. It allows us to verify:

  1. That code using our macro correctly compiles (a “pass” case).
  2. That code misusing our macro fails to compile with a specific, helpful error message (a “fail” case, which we will use later).

Dependencies vs. Dev-Dependencies: A Crucial Distinction

Before we add trybuild, it’s essential to understand a key concept in Cargo: the difference between [dependencies] and [dev-dependencies].

  • [dependencies]: These are crates that your library needs to function. When someone adds your my_builder_macro crate as a dependency to their own project, Cargo will also download and compile syn and quote because you listed them here. They are part of your crate’s public contract.
  • [dev-dependencies]: These are crates that are only needed for developing and testing your library. They are used for running tests, benchmarks, or examples. When another user depends on your crate, Cargo will ignore the [dev-dependencies] section completely. This keeps their dependency tree clean and compile times fast.

trybuild is a perfect example of a development dependency. It’s a tool for us, the crate authors, to verify our code. A user of our macro never needs trybuild to be present.

Let’s add it to your Cargo.toml file now.

Open your Cargo.toml file. Below the [dependencies] section, add a new [dev-dependencies] section and include trybuild.

[package]
name = "my_builder_macro"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

[dependencies]
quote = "1.0"
syn = { version = "1.0", features = ["full"] }

# Add this new section for development-only dependencies.
# These are not included when someone else uses your crate.
[dev-dependencies]
trybuild = "1.0"

By adding this section, you’ve told Cargo to fetch and compile the trybuild crate only when you run a command related to development, such as cargo test.

What’s Next?

With trybuild now available to our project as a development tool, the next task is to create the standard directory structure that Cargo and trybuild use for integration tests. You will create a new directory named tests in the root of your project, which will serve as the home for our testing harness.

Further Reading

To deepen your understanding of these fundamental concepts, please review the following resources:

Create the tests Directory for Integration Testing

Mục tiêu: Create the conventional tests directory at the project root. This directory is essential for housing integration tests, which will test the public API of your procedural macro.


Excellent, you have successfully configured your project to use trybuild by adding it as a development dependency. This is a critical first step. You’ve told Cargo, “When I’m developing and testing this crate, I’ll need this special tool.” Now, we need to create the space where that tool will operate.

The Anatomy of Rust Testing: Unit vs. Integration Tests

In the Rust ecosystem, testing is a first-class citizen, and Cargo has established conventions that make the process organized and powerful. There are two primary categories of tests, distinguished by where they are located in your project’s directory structure.

  1. Unit Tests: These tests are for small, isolated pieces of functionality, often individual functions. They are typically placed inside the src directory, co-located with the code they are testing, often within a module annotated with #[cfg(test)]. This allows them to test private, internal functions and implementation details.
  2. Integration Tests: These tests are designed to test your library from the outside, just as a real user would. They are placed in a special, separate directory at the root of your project. Each file inside this directory is treated as its own small crate that depends on your main library. This is a crucial distinction: integration tests can only call your library’s public API.

For our procedural macro, this is not just a best practice—it’s a necessity. A procedural macro can only be used by another crate; it cannot be used within the same crate that defines it. Therefore, an integration test is the perfect environment to simulate an external crate using our #[derive(Builder)] macro.

Cargo’s Convention: The tests Directory

Cargo isn’t magic; it follows conventions. When you run the command cargo test, one of the first things Cargo does is look for a directory named tests in the root of your project. It will then compile each .rs file within this tests directory as a separate test crate and run it.

Your current task is to create this special directory. This will be the home for our trybuild test runner and all the UI test cases that will validate our macro’s behavior.

Your project structure currently looks like this:

my_builder_macro/
├── Cargo.toml
├── Cargo.lock
├── src/
│   └── lib.rs
└── target/
    └── ...

After this step, it should look like this:

my_builder_macro/
├── Cargo.toml
├── Cargo.lock
├── src/
│   └── lib.rs
├── tests/      <-- Create this new directory
└── target/
    └── ...

Go ahead and create a new directory named tests in the root of your my_builder_macro project. You can do this from your terminal with the command mkdir tests or by using your code editor’s file explorer.

What’s Next?

You have now created the conventional home for your project’s integration tests. The directory is empty, waiting for a test runner. In the very next task, you will create a new Rust file inside this tests directory, tests.rs, which will serve as the entry point for trybuild to work its magic.

Further Reading

Understanding Rust’s testing structure is fundamental to building robust libraries. These resources from the official Rust documentation will provide a deeper understanding of the concepts discussed.

Create Integration Test Runner File in Rust

Mục tiêu: Create a new file named tests.rs inside the tests directory. This file will serve as the entry point for Cargo’s integration test runner and will be used to configure and launch the trybuild test harness for a procedural macro.


Excellent work on setting up the trybuild dependency and creating the tests directory. You’ve successfully prepared the ground for integration testing. By creating that directory, you’ve signaled to Cargo that you intend to test your library’s public API from an external perspective, which is the only way to test a procedural macro.

Now that the directory exists, we need to create the test runner file inside it. This file will act as the entry point for our entire test suite.

The Test Runner: Your Crate’s First User

When you run cargo test, Cargo looks inside the tests directory and treats every .rs file it finds as a separate mini-crate. Each of these files is compiled and executed as a test. This is powerful because it allows you to organize your integration tests into multiple files if they become complex.

For our project, we will start with a single test runner file. This file will have one primary responsibility: to configure and launch the trybuild harness. We will name this file tests.rs, which is a common and clear convention for the main integration test file of a project.

Think of the code inside tests/tests.rs as the very first “user” of your my_builder_macro crate. It will import the crate and orchestrate the process of compiling other small code samples that use the #[derive(Builder)] macro.

Your current task is simple but essential: create this empty file.

Go ahead and create a new file named tests.rs inside the tests directory you created in the previous task.

Your project structure should now look like this:

my_builder_macro/
├── Cargo.toml
├── Cargo.lock
├── src/
│   └── lib.rs
├── tests/
│   └── tests.rs  <-- Create this new file
└── target/
    └── ...

That’s it! You have now created the designated entry point for Cargo’s integration test runner. The stage is set for trybuild.

What’s Next?

With the empty tests/tests.rs file in place, the next task is to populate it with the necessary code. You will write a test function annotated with #[test] that initializes and runs the trybuild::TestCases runner, pointing it to the UI test cases we will create shortly.

Further Reading

To reinforce your understanding of how Cargo discovers and runs tests, these resources are highly recommended.

Implement the trybuild Test Runner

Mục tiêu: Write the test runner code in tests/tests.rs using the trybuild crate to set up a test harness that compiles and validates UI test cases for a Rust procedural macro.


You have perfectly prepared the testing environment for your project. By adding trybuild as a dev-dependency, creating the tests directory, and adding the tests.rs file, you have followed the established conventions for integration testing in Rust. That empty tests/tests.rs file is now a blank canvas, ready to be turned into a powerful test harness that will validate the correctness of your procedural macro.

This task is about writing the code inside tests.rs that will orchestrate the entire testing process.

The Test Runner: Orchestrating with trybuild

When you run cargo test, Cargo will compile and run the code inside tests/tests.rs as if it were a small, separate program that depends on your my_builder_macro library. The Rust testing framework looks for any function annotated with the #[test] attribute and executes it.

Our goal is to create a single test function that doesn’t perform a simple assert! itself, but instead delegates the real work to the trybuild crate. The trybuild API is designed to be straightforward and declarative. We will create an instance of its test runner and tell it what to test and what the expected outcome is.

The core of the trybuild crate is the trybuild::TestCases struct. We will use it as follows:

  1. Create a new TestCases instance.
  2. Call a method on this instance to specify a set of test files and the expected result. For now, we expect our test cases to compile successfully, so we will use the .pass() method.
  3. The .pass() method takes a string argument that is a “glob” pattern. A glob is a string with wildcard characters (*) that can match multiple file paths. We will tell trybuild to find all files ending in .rs inside a new directory we are about to create, tests/ui.

Let’s write the code for tests/tests.rs now.

#[test]
fn ui() {
    // 1. Initialize the test cases runner.
    let t = trybuild::TestCases::new();

    // 2. Tell the runner to find all Rust files in the `tests/ui` directory.
    // The `.pass()` method instructs trybuild that it should compile and run these
    // files, and that the compilation is expected to succeed. If any of them
    // fail to compile, the test will fail.
    t.pass("tests/ui/*.rs");
}

Code Breakdown

Let’s dissect this short but powerful piece of code line by line.

  • #[test]: This is a Rust attribute that marks the following function, ui(), as a test function. When you run cargo test, Cargo’s test harness will discover and execute this function. The name ui is a convention for this kind of test, but it could be named anything.
  • let t = trybuild::TestCases::new();: Here, we create an instance of the main test runner struct from the trybuild crate. We are preparing to configure our test run.
  • t.pass("tests/ui/*.rs");: This is the most important line.

    • We call the .pass() method on our TestCases instance, t. This method tells trybuild that the tests we are about to specify are “pass cases” – we expect them to compile without any errors.
    • The argument "tests/ui/*.rs" is a glob pattern. It tells trybuild to look inside a directory named tests, then inside a subdirectory named ui, and find every file that ends with the .rs extension.
    • For each file it finds, trybuild will invoke the Rust compiler (rustc) on that file. The test function ui() will only pass if all of the found files compile successfully. If even one fails, trybuild will report an error and the entire ui test will fail.

You have now created a fully functional, albeit currently empty, test harness. When cargo test is run, it will execute your ui function, which will in turn ask trybuild to look for tests in a directory that doesn’t exist yet.

What’s Next?

The test runner is now pointing to a location, tests/ui, that is waiting to be filled with actual test cases. Your very next task is to create this ui directory. After that, you will create your first test case: a simple Rust program that uses your #[derive(Builder)] macro to prove that it generates correct, compilable code.

Further Reading

To solidify your understanding of Rust’s testing framework and the tools you’re using, please explore these resources.

Create the tests/ui Directory for Rust Compile-Time Tests

Mục tiêu: Create a new directory named ui inside the tests directory to set up the project structure for trybuild compile-time UI tests in a Rust project.


You’ve done an excellent job setting up the test runner. The code you wrote in tests/tests.rs is a clear and concise instruction to the trybuild testing harness. That file is now the designated orchestrator for our entire test suite.

Let’s look again at the most critical line from that file:

t.pass("tests/ui/*.rs");

This line is more than just a piece of code; it’s a contract. We have explicitly told trybuild, “Your job is to find a directory named ui inside the tests directory, compile every Rust file (*.rs) you find there, and ensure that every single one of them compiles successfully.”

Right now, if you were to run cargo test, the test would fail with a message like panicked at 'tests/ui/*.rs: No such file or directory'. The test harness is correctly reporting that we haven’t fulfilled our side of the contract yet. The directory it’s been instructed to scan simply doesn’t exist.

Your current task is to fulfill this contract by creating that directory.

The ui Directory: A Convention for Compile-Time Testing

Why ui? This name is a strong convention in the Rust ecosystem for this type of testing. “UI” stands for “User Interface,” but in this context, it refers to the compiler’s user interface. Our tests will verify two things:

  1. That our macro’s API is easy to use and generates code that compiles cleanly (a “pass” case).
  2. That when a user makes a mistake, our macro generates code that produces clear, helpful, and predictable compiler errors (a “fail” case, which we’ll address in a later step).

By placing all these test cases in a dedicated tests/ui directory, we maintain a clean and organized separation between the test runner (tests/tests.rs) and the test cases themselves.

Your project structure currently looks like this:

my_builder_macro/
├── Cargo.toml
├── Cargo.lock
├── src/
│   └── lib.rs
└── tests/
    └── tests.rs

Your task is to create the ui subdirectory within tests. After this step, your project structure will look like this:

my_builder_macro/
├── Cargo.toml
├── Cargo.lock
├── src/
│   └── lib.rs
└── tests/
    ├── tests.rs
    └── ui/          <-- Create this new directory

Go ahead and create a new directory named ui inside the tests directory. You can do this from your terminal (mkdir tests/ui) or using your code editor’s file explorer.

What’s Next?

The stage is now perfectly set. You have the test harness ready to run, and you have the designated directory ready to hold the test cases. The trybuild runner is pointing at this new, empty directory, waiting for something to test.

In the very next task, you will create your first test case file inside this ui directory. This file, 01-builder-works.rs, will be a small, self-contained Rust program that defines a struct, derives your Builder, and successfully uses it, providing the first concrete proof that your procedural macro is a success.

Further Reading

To learn more about the conventions and tools for testing in Rust, these resources are invaluable.

  • The trybuild Documentation: The official documentation for the test runner you are using. It’s the best source for understanding its design and conventions, including the tests/ui layout.
  • The Cargo Book: Project Layout: The definitive guide from the official Cargo documentation on how Rust projects are conventionally structured, including the role of the tests directory.

Create the First ‘Happy Path’ Test Case for the Builder Macro

Mục tiêu: Create the 01-builder-works.rs test file in the tests/ui directory. This test will serve as the ‘happy path’ case, ensuring the derive macro successfully generates compiling and functionally correct builder code for a simple struct.


You have perfectly prepared the ground for a robust testing suite. The test runner is configured in tests/tests.rs, and the tests/ui directory you just created is the designated arena where your procedural macro will prove its worth. That empty directory is a promise, and now it’s time to deliver on it by creating your very first test case.

This first test case is arguably the most important one you will write. It’s the “happy path” test. Its sole purpose is to demonstrate that when your macro is used correctly on a simple, well-behaved struct, it generates code that compiles perfectly and functions as expected. This test will be the fundamental proof that your core logic works.

Creating the First Test Case

Your task is to create a new file inside the tests/ui directory. We will follow a common convention and name it 01-builder-works.rs. The numbering helps to keep tests in a predictable order as your suite grows.

This file will be a small, self-contained Rust program. The trybuild harness will compile this file as if it were a standalone binary. For the test to pass, this compilation must succeed without any errors.

Here is the code you should place inside tests/ui/01-builder-works.rs:

// 1. Import the derive macro from your crate.
// The name `my_builder_macro` is what you specified in your `Cargo.toml` file.
// This `use` statement makes the `#[derive(Builder)]` attribute available.
use my_builder_macro::Builder;

// 2. Define a simple struct to test the macro on.
// This struct has fields with common types (`String`, `Vec<String>`, `u64`)
// that a real-world builder would need to handle.
// Importantly, we also derive `Debug` and `PartialEq` to make it easy to
// print and compare in our test assertions later.
#[derive(Builder, Debug, PartialEq)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    current_dir: String,
    timeout: u64,
}

// 3. The `main` function is the entry point. `trybuild` compiles this
// file as a binary, so `main` is where we'll execute our test logic.
fn main() {
    // 4. Use the generated builder to construct an instance of `Command`.
    // This is the core of the test. We are calling the functions that
    // our procedural macro is supposed to generate.
    let builder = Command::builder();

    let command = builder
        .executable("cargo".to_string())
        .args(vec!["build".to_string(), "--release".to_string()])
        .current_dir("/tmp/foo".to_string())
        .timeout(60)
        .build();

    // 5. Create an expected instance of the struct manually.
    // This gives us a "known good" value to compare against.
    let expected = Command {
        executable: "cargo".to_string(),
        args: vec!["build".to_string(), "--release".to_string()],
        current_dir: "/tmp/foo".to_string(),
        timeout: 60,
    };

    // 6. Assert that the built command is equal to the expected one.
    // This goes beyond just checking if the code compiles. It verifies that
    // the generated setters and `build` method work correctly and produce
    // the right data. If this assertion passes, we know our macro is
    // functionally correct for this happy path.
    assert_eq!(command, expected);
}

Deconstructing the Test Case

Let’s break down why this file is a comprehensive first test:

  • use my_builder_macro::Builder;: This is how an external crate (our test case) imports and uses a procedural macro. The name of your crate (my_builder_macro) acts as the path to the Builder derive macro you defined.
  • #[derive(Builder, Debug, PartialEq)]: Here, we apply our macro to the Command struct. We also derive Debug and PartialEq, which are standard traits that allow us to print the struct for debugging and compare two instances for equality with ==, respectively. This is essential for the assert_eq! macro to work.
  • A main Function: trybuild compiles each file in the ui directory as a separate program. A main function is required as the program’s entry point.
  • Exercising the API: Inside main, we don’t just define the struct; we actively use the generated API. The line Command::builder() tests the convenient entry point you created. The chained .executable(...), .args(...), etc., calls test the fluent setters. The final .build() call tests the object construction logic.
  • assert_eq!: This is a powerful step. Merely compiling is a good sign, but asserting that the final object has the exact state we expect proves that the generated logic is not just syntactically correct, but also semantically correct.

What’s Next?

The moment of truth has arrived. You have your procedural macro, and you have a test case that uses it. The only thing left to do is to run the test and see it pass. In the final task of this step, you will run the cargo test command and watch as trybuild picks up your new test case, compiles it, runs it, and reports a resounding success.

Further Reading

To deepen your understanding of the concepts used in this test case, explore the following resources:

Run and Verify the Procedural Macro with cargo test

Mục tiêu: Execute the cargo test command to run the trybuild integration test suite. This will compile and validate the code generated by your custom Builder derive macro, confirming that the implementation is correct.


The stage is set, the actors are in position, and the script is written. In the last task, you meticulously crafted your first test case, 01-builder-works.rs, a complete program designed to use and validate every part of the builder API your macro generates. The trybuild test runner is configured and waiting in tests/tests.rs, pointing directly at this file. Every piece is in place.

This is the moment of truth. All the complex logic for parsing, code generation, and assembly will now be put to the ultimate test: will it compile and run correctly? It’s time to execute the test and watch your creation come to life.

The Final Command: cargo test

Open your terminal in the root directory of your my_builder_macro project. The command to unleash the entire testing apparatus you’ve built is simple and familiar:

cargo test

When you press Enter, a symphony of tools will spring into action. It’s not magic; it’s a well-orchestrated process that validates all your hard work.

Behind the Scenes: A Symphony of Tools

Let’s break down exactly what happens when you run cargo test in this project:

  1. Discovery: Cargo scans your project. It finds the [dev-dependencies] and sees your tests directory. It recognizes tests/tests.rs as an integration test crate.
  2. Compilation (Test Runner): Cargo compiles tests/tests.rs, linking it with your main my_builder_macro library and the trybuild crate.
  3. Execution (Test Runner): The compiled tests.rs binary is executed. The Rust test harness finds the function annotated with #[test], which is our ui() function, and runs it.
  4. trybuild Takes Over: Inside ui(), the trybuild::TestCases runner is initialized. The crucial line, t.pass("tests/ui/*.rs"), is executed.
  5. Discovery (Test Cases): trybuild scans the tests/ui/ directory and finds one matching file: 01-builder-works.rs.
  6. Compilation (Test Case): This is the core of the process. trybuild invokes the Rust compiler (rustc) on tests/ui/01-builder-works.rs.
  7. Macro Expansion: The compiler sees #[derive(Builder)] on the Command struct. It pauses compilation, loads your my_builder_macro crate, and calls the derive_builder function, passing it the tokens for the Command struct.
  8. Code Generation: Your macro runs! It parses the struct, identifies the fields, and generates the TokenStream for the CommandBuilder struct, its impl block (new, setters, build), and the impl Command block (builder function).
  9. Code Injection: Your macro returns the final TokenStream to the compiler. The compiler seamlessly replaces #[derive(Builder)] with the full block of generated code. From the compiler’s perspective, it’s as if you had written all that builder code by hand.
  10. Final Compilation and Verification: The compiler continues compiling the rest of 01-builder-works.rs. The code in the main function is now valid because Command::builder(), .executable(), .build(), and all the other methods exist. The compilation succeeds. trybuild sees that the compilation succeeded, just as it was instructed to expect by the .pass() method. The test case is a success.
  11. Success!: Since all files found by the glob pattern passed, trybuild reports success. The ui() test function completes without panicking.
  12. Final Report: Cargo’s test harness reports that the one test function (ui) passed.

The Expected Output

After the compilation finishes, you should see a clean and satisfying output in your terminal, indicating that everything worked perfectly.

   Compiling my_builder_macro v0.1.0 (/path/to/my_builder_macro)
    Finished test [unoptimized + debuginfo] target(s) in 1.50s
     Running unittests (target/debug/deps/my_builder_macro-...)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/tests.rs (target/debug/deps/tests-...)

running 1 test
test ui ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.80s

   Doc-tests my_builder_macro

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The most important line is test ui ... ok. This is the confirmation that your integration test ran and passed, which in turn means your procedural macro generated correct, compilable, and functional code.

A Monumental Achievement

Congratulations! You have successfully built and verified a complete, end-to-end procedural macro. This is a significant accomplishment that demonstrates a deep understanding of some of Rust’s most powerful and advanced features. You have moved beyond just writing Rust code to writing Rust code that writes other Rust code.

What’s Next?

Your macro works perfectly for the “happy path.” But what about more complex, real-world scenarios? A robust library must be flexible and provide clear guidance when misused. The foundation is solid; now it’s time to build upon it.

The next steps in the project roadmap will focus on these enhancements:

  1. Handling Optional Fields: What if a user’s struct already has an Option<T> field? Our builder should handle this gracefully, not requiring it to be set.
  2. Robust Error Handling: Currently, forgetting to set a field causes a panic at runtime when .build() is called. You will enhance the macro to return a Result, providing a clear, compile-time error message if a required field is missing.

You will expand your trybuild suite with new test cases—some that pass to verify new features, and some that are expected to fail to verify your new, improved error messages.

Further Reading

To solidify your understanding of Rust’s testing ecosystem, dive into these resources.