Testing with trybuild¶
Level: 201 · working knowledge
One line: trybuild compiles small programs against your crate, t.pass expecting each to build and run, t.compile_fail expecting each to be refused with exactly the error recorded in a .stderr file beside it, so the messages your users see are under test too.
The test¶
This workspace is the facade and derive from The re-export pattern, with the two crates named tested_describe and tested_describe_derive, and the generated paths changed to match: tested_describe holds the trait, and tested_describe_derive refuses anything but a struct with named fields. The UI tests live in the facade, because a test program has to use the derive the way a user does:
[package]
name = "tested_describe"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
tested_describe_derive = { path = "../tested_describe_derive" }
[dev-dependencies]
trybuild = "1"
//! Every file in `tests/ui/`, compiled against this crate as a program of its own.
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.pass("tests/ui/pass_*.rs");
t.compile_fail("tests/ui/fail_*.rs");
}
Each file under tests/ui/ is a whole program. One should compile and run:
use tested_describe::Describe;
#[derive(Describe)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 1, y: 2 };
assert_eq!(p.describe(), "Point: x = 1, y = 2");
}
Two should not, and each has the error it must produce recorded beside it:
use tested_describe::Describe;
#[derive(Describe)]
enum Direction {
Up,
Down,
}
fn main() {}
error: Describe needs a struct
--> tests/ui/fail_enum.rs:4:6
|
4 | enum Direction {
| ^^^^^^^^^
use tested_describe::Describe;
#[derive(Describe)]
struct Meters(f64);
fn main() {}
error: Describe needs named fields
--> tests/ui/fail_tuple_struct.rs:4:8
|
4 | struct Meters(f64);
| ^^^^^^
Verified output of cargo test -q -p tested_describe --test ui — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
test tests/ui/pass_named_fields.rs [should pass] ... ok
test tests/ui/fail_enum.rs [should fail to compile] ... ok
test tests/ui/fail_tuple_struct.rs [should fail to compile] ... ok
- One
#[test], three cases.trybuildruns its cases when theTestCasesvalue is dropped ↗, at the end ofui(), and libtest sees one test. The globspass_*.rsandfail_*.rspick up new cases without editingui.rs. - Every case is built as a real program.
trybuildgenerates a package calledtested_describe-tests, intests/trybuild/tested_describeunder the target directory ↗, with one binary per case. It depends ontested_describe, and on everythingtested_describedepends on, dev-dependencies included ↗,trybuilditself excepted, so a case canuse tested_describe_derivedirectly too. Apasscase is built and run, so theassert_eq!inpass_named_fields.rsis checked too. - What the key leaves out. The key is
trybuild's report on stderr. That build prints Cargo's progress lines,Compiling tested_describe-tests v0.0.0 (/…)andFinished … in 0.30s, which hold absolute paths and timings and appear only when something was rebuilt; the declared run deletes them. libtest's ownrunning 1 testsummary goes to stdout and is not in the key.
Where a .stderr file comes from¶
Not from typing it. Run the tests once with the variable set:
and trybuild writes each compile_fail case's output to its .stderr file. Without the variable, a case with no .stderr file gets its output written to a wip/ directory beside Cargo.toml instead, and the test fails ↗ until you move the file into place. Both workflows are in the crate's documentation ↗. Read the diff before committing, as with any recorded key.
The file is not the compiler's raw output. trybuild normalizes it, line by line, so it holds on another machine:
- Paths become relative. rustc was compiling a file outside the generated package, so it printed an absolute path;
trybuildstrips the crate's directory ↗, leavingtests/ui/fail_enum.rs:4:6. A path into the standard library becomes$RUST/…↗, one into the Cargo registry$CARGO/…↗, and line numbers outside the test file are blanked. - Cargo's closing lines are gone.
error: could not compile …andFor more information about this error, try rustc --explain …are dropped ↗; compare thecargo buildtranscripts on The re-export pattern, which keep both. - Trailing whitespace is trimmed.
The message text and the ^^^^ under the name are left alone. They are what the test is for.
A regression, recorded¶
tests/ui_stale/fail_enum.stderr was recorded with TRYBUILD=overwrite while the derive still said Describe can only be derived for a struct. The message was then changed to Describe needs a struct, and this file was kept as it was:
//! One failing input, held to a `.stderr` file recorded before the derive's
//! message was reworded.
#[test]
fn ui_stale() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui_stale/fail_enum.rs");
}
error: Describe can only be derived for a struct
--> tests/ui_stale/fail_enum.rs:4:6
|
4 | enum Direction {
| ^^^^^^^^^
Verified output of cargo test -q -p tested_describe --test ui_stale, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
test tests/ui_stale/fail_enum.rs ... mismatch
EXPECTED:
┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
error: Describe can only be derived for a struct
--> tests/ui_stale/fail_enum.rs:4:6
|
4 | enum Direction {
| ^^^^^^^^^
┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
ACTUAL OUTPUT:
┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
error: Describe needs a struct
--> tests/ui_stale/fail_enum.rs:4:6
|
4 | enum Direction {
| ^^^^^^^^^
┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈
note: If the actual output is the correct output you can bless it by rerunning
your test with the environment variable TRYBUILD=overwrite
error: test failed, to rerun pass `-p tested_describe --test ui_stale`
EXPECTED is the file, ACTUAL OUTPUT is today's compiler output after normalization, and the case fails on the one line that differs. The same filter as above removed Cargo's progress lines. The last line is Cargo's own, because the test binary panicked; the panic message itself, 1 of 1 tests failed, is on stdout.
A mismatch means one of two things, and trybuild cannot tell which. If the change was a regression, fix the macro. If it was intended, as here, bless the new output by running with TRYBUILD=overwrite again and commit the updated .stderr. A new compiler release that rewords a note is the same kind of mismatch, which is one reason the library pins its toolchain.
Unit tests and UI tests¶
The two halves test different things, and a derive wants both:
Unit test of expand |
trybuild UI test |
|
|---|---|---|
| Written against | proc_macro2 tokens, in #[cfg(test)] beside the logic |
a whole program, as a user writes it |
| Can check | the generated tokens, and an error's message | whether the output compiles and runs, and the error exactly as rustc prints it, with its location and underline |
| Cannot check | whether the generated code compiles, or where an error points | anything that does not show in a build |
| Runs | the logic as a function, with no compiler involved | a Cargo build of the generated package |
proc-macro2 makes it testable is the first column: its refuses_an_enum test asserts an error's text. The UI test above also pins down that rustc underlines Direction, the span the derive chose, and not enum.
If you are coming from another language¶
- Java. Google's
compile-testinglibrary runsjavacin-process on source strings, with an annotation processor attached, and asserts.failed()and.hadErrorContaining("…"). That iscompile_failwith a substring match.trybuildcompares the whole normalized message, location and underline included, against a file, so any change shows up in review as a diff of that file. - Python. The nearest habit is snapshot testing, with
syrupyorpytest-snapshot, whose--snapshot-updatedoes whatTRYBUILD=overwritedoes. The thing being snapshotted is what differs: a Python snapshot holds a value a function returned, and a.stderrfile holds what a compiler said about a program. - C and C++. Compilers test their own diagnostics this way: clang's test suite compiles a file with
-verify, and// expected-error {{…}}comments beside the offending line say which errors must appear. That harness belongs to the compiler.trybuildgives the same check to a library, such as a derive whose errors are part of its interface, with one dev-dependency.
See also¶
proc-macro2makes it testable — the unit-test half- The re-export pattern — the facade and derive tested here
- Errors: from
panic!tosyn::Error— the messages worth testing - Where a test goes — why
tests/ui.rsis an integration test - The
trybuildcrate ↗
Po polsku¶
trybuild kompiluje małe programy korzystające z twojego crate'a: przypadki z t.pass(…) muszą się zbudować i uruchomić, a przypadki z t.compile_fail(…) — zostać odrzucone z dokładnie takim błędem, jaki zapisano w pliku .stderr obok. Dzięki temu komunikaty, które widzi użytkownik makra, też są pod testem. Plik .stderr generuje się przez TRYBUILD=overwrite cargo test, a trybuild go normalizuje: ścieżki stają się względne, a końcowe linie Cargo (error: could not compile …) znikają, więc plik jest ten sam na każdej maszynie.
Gdy komunikat makra się zmieni, test pokazuje EXPECTED i ACTUAL OUTPUT — to regresja albo zamierzona zmiana, którą zatwierdza się ponownym uruchomieniem z TRYBUILD=overwrite. Testy jednostkowe funkcji expand na proc_macro2 sprawdzają wygenerowane tokeny; trybuild sprawdza, czy kod się kompiluje i gdzie kompilator wskazuje błąd.
Szukaj po polsku: testowanie komunikatów błędów makr w Ruscie · trybuild compile_fail · TRYBUILD=overwrite · rust ui test proc macro · trybuild stderr mismatch