Dissenting Opinion

A Personal Blog for Steven Bosnick

Procedural Macro Testing in Rust

17 Jun 2018

As my inaugural post I wanted to share a technique I put together for testing procedural macros in Rust. This came up as a part of implementing my embedded lexer generator Luther.

The root of the problem that I had to solve was that proc-macro crates cannot have unit tests like most other Rust crates can. They can have integration tests as described in (among other places) Brandon W Maister’s post Debugging Rust’s new Custom Derive system.

Integration tests are very useful for testing the code produced by an invocation of a proc-macro crate, but they can’t directly test the proc-macro crate itself. An integration test can indirectly test the success paths through a proc-macro crate since the test cannot run if it cannot compile, and it cannot compile if the proc-macro crate does not run successfully. What they can’t test, even indirectly, is the failure cases in the proc-macro crate. Integration tests also cannot allow for test coverage reporting (i.e. on Coveralls).

Phase Differences

The source of the problem and the starting point for the solution is the phase difference between proc-macro crates and the testing infrastructure built into cargo through the cargo test command. Proc-macro crates are “run” at compile time while the testing infrastructure is “run” at run time. Crates like syn and quote help reduce the dissonance between “compile time” programming and “run time” programming, but it is always present.

The key insight for me was that when proc-macro crates “run” they run as a part of the compiler. I was always vaguely aware of this, but syn, quote and the compiler’s proc_macro crate allowed me to paper over this fact while I was writing my own proc-macro crate.

The testing technique I put together based on this insight was to run rustc on carefully crafted input files that use my proc-macro crate and then compare the exit status of rustc to the expected exit status to determine if the test succeeded or failed. (This appears to be the same strategy that the compiler itself uses for tests.)

Running Rustc

After a bit of experimentation I was finally able to determine a set of options to rustc that produced the results I was looking for. I wanted a command line that would compile my simple one-file crates such as the following:

// succ_simple_lib.rs

#![crate_type = "lib"]

extern crate luther;

#[macro_use]
extern crate luther_derive;

#[derive(Lexer, Debug)]
pub enum Token {
    #[luther(regex = "ab")] Ab,
    #[luther(regex = "acc*")] Acc,
    #[luther(regex = "a(bc|de)")] Abcde(String),
}

The luther crate named above is the runtime crate that (among other things) defines the Lexer trait while the luther_derive crate is the proc-macro crate that generates the code to implement the trait for (in this case) the Token enum. The luther_derive crate is the crate that is run at compile time.

The rustc invocation that I found to work for compiling these one-file crates is the following:

$ rustc -L dependency=target/debug/deps
        --extern luther=target/debug/libluther.rlib 
        --extern luther_derive=target/debug/libluther_derive.so
        --out-dir path/to/unique/location
        succ_simple_lib.rs

This assumes that you have already run cargo build and that it is being run from the root of workspace that has both the luther and luther_derive crates as members.

It is useful to go through the options on this rustc invocation one by one.

Option Value Comment
-L dependency=… The location of the dependencies of the luther and luther_derive crates.
--extern luther=… The location of the runtime library crate.
--extern luther_derive=… The location of the compile time proc-macro crate.
--out-dir path The location into which to generate the output artifacts of rustc

Putting It All Together

Once I had figured out the rustc invocation to use, I was able to put this at the heart of a test suite runner that invoked rustc on each of the one-file crates in the test suite and compared the exit code of rustc with the expected exit code. The convention that I adopted was that files that started with “succ” indicated tests that should succeed and test that started with “fail” indicated test that should fail.

It is even possible to run rustc with these options under kcov which allowed me to collect test coverage information for my proc-macro crate.

Conclusion

cargo together with the syn and quote crates provide an means of papering over the runtime/compile time phase difference between normal Rust library crates and proc-macro crates. For this problem of directly testing the proc-macro crates, however, the ability to face the phase difference head on by running rustc directly was essential.