Library / SDK
dtolnay/trybuild avatar
dtolnay/trybuild

trybuild: asserting the exact error text your Rust library produces

Test harness for ui tests of compiler diagnostics

1,009 stars86 forksRustApache-2.0

At a glance

What is it?
trybuild is a test harness that invokes rustc on fixture files and compares the resulting diagnostics against stored .stderr files, which is how a procedural macro author checks that its error messages stay friendly. It suits libraries whose user-facing failure is a compile error, and it couples your expectations to the compiler version you test with.
Who is it for?
Adopt trybuild if your Rust library reports errors through the compiler, whether from a procedural macro or from misuse of an ordinary API, because the harness checks the message and the span the user will actually see rather than your internal logic. Do not adopt it for libraries that fail at runtime, and be aware that the expected files are compiler output snapshots, so a rustc upgrade can change them.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 16 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the harness actually checks

trybuild is a test harness for invoking rustc on a set of test cases and asserting that any resulting error messages are the ones intended.

That last phrase is the whole idea, and it is narrower than it first sounds. The harness does not check that your code returns an error. It checks that the text rustc prints, the wording, the line and column markers, the caret under the offending token, matches a file you committed. If the compiler changes how it formats a diagnostic, the test fails, and someone has to decide whether the new message is acceptable.

The README says such tests are commonly useful for testing error reporting involving procedural macros, with test cases triggering either errors detected by the macro or errors detected by the Rust compiler in the resulting expanded code, and comparing against the expected errors to ensure they remain user friendly.

It also says the style of testing is sometimes called ui tests, because it tests aspects of the user's interaction with a library outside of what would be covered by ordinary API tests, and that nothing here is specific to macros, since trybuild would work equally well for testing misuse of non-macro APIs.

That last point matters for the design. The tests exercise the crate the way a user does, through the compiler, so the thing being asserted is the experience rather than the implementation.

Setting it up: a dev-dependency and one test function

The integration is two parts, and the first is a dev-dependency in the project's Cargo.toml.

toml
[dev-dependencies]
trybuild = "1.0"

The second is a test function that constructs the test cases and points them at the fixtures.

rust
#[test]
fn ui() {
    let t = trybuild::TestCases::new();
    t.compile_fail("tests/ui/*.rs");
}

The test is then run with cargo test. The harness individually compiles each of the source files matching the glob pattern, expects them to fail to compile, and asserts that the compiler's error message matches an adjacently named .stderr file, which has the same file name as the test case with a different extension. If it matches, the test case is considered to succeed.

Dependencies listed under dev-dependencies in the project's Cargo.toml are accessible from within the test cases, so a fixture can use the crate it is testing.

Running one case at a time is supported, which matters when a suite has a hundred fixtures and one of them broke.

bash
cargo test -- ui trybuild=example.rs

Here ui is the name of the test function that invokes trybuild, and example.rs is the name of the file to test.

The .stderr contract, and why failing to fail is a failure

Two rules define the contract, and the second one is the one people forget.

The first is that a compile-fail fixture must produce exactly the stored diagnostics. The expected output is a .stderr file sitting next to the .rs file, and the harness compares the compiler's output against it, character for character in the areas that matter, including the spans.

The second is that a compile_fail test which fails to fail to compile is also a failure. That is, if the code compiles when it was supposed not to, the test fails, not passes. This is the rule that makes the suite a regression net rather than a snapshot of current behaviour. Without it, a macro that stops rejecting bad input would quietly turn its test green.

The stored files are ordinary text, which is why the workflow around them matters. When a test fails, the harness displays the expected and the actual compiler output inline, so you can see the difference in the terminal without opening the files.

The screenshot-heavy README is showing exactly this output, and the same inline diff view is what you would use to judge whether a compiler upgrade changed a message in a way you can live with.

Pass tests, and where the harness came from

The same harness can run tests that are expected to succeed, and the README is candid that you would normally just let Cargo run those directly.

The stated reason for the feature is combining modes, which would be useful for workshops in which participants work through test cases enabling one at a time. The example mixes both kinds in a single test function, with pass cases and a compile_fail case, and some cases commented out.

rust
#[test]
fn ui() {
    let t = trybuild::TestCases::new();
    t.pass("tests/01-parse-header.rs");
    t.pass("tests/02-parse-body.rs");
    t.compile_fail("tests/03-expand-four-errors.rs");
    t.pass("tests/04-paste-ident.rs");
    t.pass("tests/05-repeat-section.rs");
}

The rule for a pass case is that it succeeds if it compiles and has a main function that does not panic when the compiled binary is executed. So a pass test is not just a compile check, it also runs the program, which is what makes it useful for a fixture that is supposed to work.

The origin explains the feature. Trybuild was originally developed for a procedural macros workshop, and that is where the pass mode comes from, since a workshop wants a set of files that the participant enables one at a time.

Updating the expectations: the wip directory and TRYBUILD=overwrite

There are two ways to update the .stderr files while you iterate, and the README says handwriting them is not recommended.

The first is the wip directory. If a test case is being run as compile_fail but a corresponding .stderr file does not exist, the test runner saves the actual compiler output with the right filename into a directory called wip within the directory containing Cargo.toml. You update the files by deleting them, running cargo test, and moving all the files from wip into your test case directory.

The second is an environment variable. Running cargo test with TRYBUILD=overwrite skips the wip directory and writes all compiler output directly in place. The README warns that you will want to check git diff afterward to be sure the compiler's output is what you had in mind.

That warning is the important part of this section. The overwrite mode is the fast path and it is also the dangerous one, because it replaces your recorded expectation with whatever the compiler said, and the reason you ran it in the first place is usually that the output changed. If a compiler upgrade changed the wording of a diagnostic for no good reason, overwriting the file records the regression as the new normal.

The wip directory is slower and safer for exactly that reason, since moving the file is a separate, visible act.

What is worth testing, and what is not

The section on what to test is short and opinionated, and it is the part of the README worth reading twice.

The positive instruction is to write compile-fail tests for anything you care to find out about when there are changes in the user-facing compiler output. The negative instruction is explicit: do not write compile-fail tests simply calling all of your public APIs with arguments of the wrong type, because there would be no benefit.

That is a useful piece of advice, because the naive version of this testing style is a combinatorial mess. A crate with two hundred public functions could generate two hundred type-error fixtures, none of which would ever be read, and every one of which would need updating when the compiler's wording shifted.

The examples show the cases that do earn their place. A derive macro that requires a specific attribute to be present, where the error tells the user which attribute is missing, and a macro that consumes helper attributes, where the question is whether the error message is correctly placed under the erroneous tokens and not on a useless call_site span.

console
error: unknown serde field attribute `qqq`
 --> $DIR/unknown-attribute.rs:5:13
  |
5 |     #[serde(qqq = "...")]
  |             ^^^

The span is the thing being tested. Whether the caret points at the right token is the difference between a diagnostic that helps and one that sends the reader hunting.

The limits: compiler coupling and an experimental diff view

Two practical constraints and one small piece of news.

The first constraint is the obvious one and worth stating anyway. The expected outputs are snapshots of rustc's diagnostics, and rustc's diagnostics change between releases. Your suite is therefore coupled to the toolchain you test with, and a rustc upgrade can turn a green suite red for reasons that have nothing to do with your library. The crate's own minimum supported Rust version is 1.88, and the package is on edition 2024, so the crate tracks a recent toolchain.

The second constraint is scope. trybuild tests what the compiler says. It cannot test a runtime panic message, it cannot test behaviour, and it cannot tell you that a fixture stopped being a good example of the error you wanted to test. It is one layer of a test suite, not the suite.

The small piece of news is the diff feature. It is marked experimental, it highlights the difference between the expected and the actual compiler output, and it is currently unix only. It pulls in an optional dependency called dissimilar, and the README asks for feedback in the project issue tracker if you try it.

The crate itself is small and focused. It depends on glob for the patterns, serde and serde_json for the manifest and the comparison data, toml for parsing Cargo metadata, termcolor for the coloured diff output, and target-tuple for the target triple handling. The version is 1.0.122, the licence is MIT OR Apache-2.0, and the last push to the repository was on 2026-10-07.

Editorial conclusion

Adopt trybuild if your Rust library reports errors through the compiler, whether from a procedural macro or from misuse of an ordinary API, because the harness checks the message and the span the user will actually see rather than your internal logic. Do not adopt it for libraries that fail at runtime, and be aware that the expected files are compiler output snapshots, so a rustc upgrade can change them. Before you commit a regenerated .stderr file, read the diff, because the TRYBUILD=overwrite workflow will happily write whatever the compiler now says.

Frequently asked questions

How do I set up trybuild for compile-fail tests?

Add trybuild to dev-dependencies in Cargo.toml, then write a test that creates trybuild::TestCases and calls compile_fail with a glob such as t.compile_fail("tests/ui/*.rs"). Run it with cargo test, and the harness compiles each file, expects failure, and compares the output against an adjacent .stderr file.

What happens if a compile-fail test starts compiling successfully?

That is a failure. The README states that a compile_fail test which fails to fail to compile is also a failure, which is what stops the suite from quietly accepting a macro that no longer rejects bad input.

How do I update the expected .stderr files?

Delete the stale .stderr file, run cargo test, and move the generated file from the wip directory into your test case directory, or run cargo test with TRYBUILD=overwrite to write the output in place. The README recommends checking git diff afterwards, since hand-editing the files is not recommended.

Can trybuild test code that is expected to compile?

Yes, through t.pass. Pass cases succeed if they compile and their main function does not panic when the compiled binary runs, and the harness can mix pass and compile_fail cases in the same test function, which is how it was used for a procedural macros workshop.

Official sources

  1. dtolnay/trybuild on GitHub
  2. Issues
  3. License: Apache-2.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dtolnay-trybuild.svg)](https://hysenlabs.com/projects/dtolnay-trybuild)

Was this useful?