What a test asserts¶
Level: 201 · working knowledge
One line: A test is a function that panics when it is unhappy — there is no assertion library to learn — and the only real skill is writing assertions that can actually fail.
assert! panics if a condition is false, assert_eq! panics if two values differ, assert_ne! if they do not. #[test] marks the function, the harness runs it, and a panic is a failure. That is the whole mechanism.
assert_eq! over assert!, whenever both sides are values¶
The same wrong answer, twice:
assert!(tally(&[5, 3, 0]) == 9)
assertion failed: tally(&[5, 3, 0]) == 9
assert_eq!(tally(&[5, 3, 0]), 9)
assertion `left == right` failed
left: 8
right: 9
The first tells you a condition was false, which you already knew from the line number. The second tells you the answer was 8, which is the thing you were about to go and find out.
The message¶
Everything after the condition is a format! call, evaluated only on failure — so it costs nothing on the happy path. Spend one whenever the values alone do not say which is which.
The trap: the assertion that cannot fail¶
Green, and it has tested nothing. Every plausible implementation returns 1.0 for that input — the mean, the median, the maximum, the first element, and a function that ignores its argument. An input whose every candidate answer is the same number tests the arithmetic, not the definition.
The same shape appears whenever the expected value is a type's default: assert_eq!(tally(&[]), 0) passes because 0 is what an empty sum means. That is worth writing anyway — it pins the empty case — but it is not evidence that tally adds.
Floats: == is the wrong assertion¶
fn main() {
println!("{}", (1.0f64 / 3.0) * 3.0 == 1.0); // true
println!("{}", 0.1f64 + 0.2 == 0.3); // false
}
Two lines, both innocent-looking, and they disagree. assert_eq! on floats compares bit patterns, so whether a test passes depends on which rounding accidents cancel — and which of those two lines you meet first is luck. Compare against a tolerance instead, and pick the tolerance from the domain: on a 0–5 ballot, 1e-12 is absurd precision and 0.005 is honest.
There is a sharper version of this in the practice: assert_eq!(average(&[1, 2, 2]), Some(5.0 / 3.0)) passes, because both sides compute the same expression the same way. Write the decimal literal 1.6666666666666667 and it still passes; write 1.666666666666667 and it fails. That is a test of your typing.
If you are coming from another language¶
- Python.
assert x == yis the same statement, andpytest's rewriting is what gives you the left/right output thatassert_eq!gives here — so the ergonomics land in the same place by different routes. Two differences. Python's bareassertis removed under-O, so it is unsafe for anything but tests; Rust'sassert!is always compiled (debug_assert!is the one that vanishes in release). And there is nounittest-styleself.assertEqualvocabulary to learn, no fixtures decorator, and no plugin ecosystem in the standard workflow — which is a real loss for parameterised tests and a real gain for reading somebody else's suite.pytest.approxis the tolerance comparison you should be reaching for on floats in both languages. - ABAP. ABAP Unit's
cl_abap_unit_assert=>assert_equals( act = … exp = … msg = … )isassert_eq!with the arguments named, and themsgparameter is the message above. The habits transfer almost completely:assert_equalsoverassert_true( act = ( a = b ) )is exactly the advice in this page's second section, and for the same reason — the framework can only print what you handed it. Two ABAP-specific carry-overs worth keeping:assert_equalson a packed number withtolis the float tolerance argument, and the ABAP habit of assertingsy-subrcis the thingResultmakes unnecessary here. What Rust does not have isRISK LEVEL/DURATIONannotations;#[ignore]is the whole of that vocabulary. - Java / C#. JUnit's
assertEquals(expected, actual)— note the argument order is the opposite ofassert_eq!(actual, expected)by convention, which matters only for reading the failure output. AssertJ and FluentAssertions have no Rust counterpart, deliberately.
The verified output¶
Verified output of what_a_test_asserts.rs — regenerated by tools/run_examples.py, never hand-typed.
1. A test is a function that panics when it is unhappy
assert!(cond) panics if cond is false
assert_eq!(a, b) panics if a != b, and PRINTS BOTH
assert_ne!(a, b) panics if a == b
That is the whole mechanism: #[test] marks a function, the
harness runs it, and a panic is a failure. There is no assertion
library to learn and no `expect(x).to.equal(y)` grammar.
2. The difference between the two, on the same wrong answer
assert!(tally(..) == 9):
assertion failed: tally(&[5, 3, 0]) == 9
assert_eq!(tally(..), 9):
assertion `left == right` failed
left: 8
right: 9
The first tells you a condition was false, which you knew from
the line number. The second tells you the answer was 8. Reach for
assert_eq! whenever both sides are values.
3. The message, for when the values are not enough
seats = 3, but this election has 2 to fill
Everything after the condition is a format! call, run only on
failure. Use it when the values alone do not say what went wrong.
4. The assertion that passes for the wrong reason
assert!(tally(&[]) == 0) passes: true
assert_eq!(tally(&[]), 0) passes too
Both are green, and neither has tested anything: an empty input
summing to zero is what `0` means, not what `tally` does. A test
whose expected value is a type's default is worth re-reading —
it is the shape a test takes when it was written to pass.
5. Floats, where == is the wrong assertion entirely
(1.0 / 3.0) * 3.0 == 1.0 is true
0.1 + 0.2 == 0.3 is false
assert_eq! on floats tests bit equality, which is almost never
what you meant. Compare against a tolerance:
(0.1 + 0.2 - 0.3).abs() < 1e-10 is true
Note the first line: this one happens to be true, and the second
is false. Which of the two you meet first is luck, and that is
the argument for never writing == on a float in a test at all.
Practice¶
Five assertions, and two of them cannot fail. Write an average that returns Option<f64> and refuses an empty slice, then assert five things about it: a clear case, the empty case, a halving case, average(&[1, 1, 1]) == Some(1.0), and average(&[1, 2, 2]) == Some(5.0 / 3.0).
All five pass. Two of them would pass against a function that is wrong — say which two, and for each say what would have to be broken before it noticed. Then replace the float one with an assertion that means something, and justify the tolerance you picked in terms of what the numbers are.
Solution
what_a_test_asserts_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: five assertions, and two of them cannot fail.
//!
//! rustc --edition 2024 what_a_test_asserts_kata.rs -o /tmp/wtak && /tmp/wtak
fn message_from(f: impl FnOnce() + std::panic::UnwindSafe) -> String {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = std::panic::catch_unwind(f);
std::panic::set_hook(hook);
match result {
Ok(()) => "PASSED".to_string(),
Err(e) => e
.downcast_ref::<String>()
.cloned()
.or_else(|| e.downcast_ref::<&str>().map(|s| (*s).to_string()))
.unwrap_or_else(|| "(non-string panic)".to_string())
.lines()
.next()
.unwrap_or("")
.to_string(),
}
}
/// The function under test: an average that refuses an empty ballot.
fn average(scores: &[u32]) -> Option<f64> {
if scores.is_empty() {
return None;
}
Some(f64::from(scores.iter().sum::<u32>()) / scores.len() as f64)
}
fn main() {
println!("1. The five assertions, and what each one proves");
println!(" a. average(&[5, 3, 4]) is Some(4.0) -> {}",
message_from(|| assert_eq!(average(&[5, 3, 4]), Some(4.0))));
println!(" b. average(&[]) is None -> {}",
message_from(|| assert_eq!(average(&[]), None)));
println!(" c. average(&[1, 2]) == Some(1.5) -> {}",
message_from(|| assert_eq!(average(&[1, 2]), Some(1.5))));
println!(" d. average(&[1, 1, 1]) == Some(1.0) -> {}",
message_from(|| assert_eq!(average(&[1, 1, 1]), Some(1.0))));
println!(" e. average(&[1, 2, 2]) == Some(5.0/3.0) -> {}",
message_from(|| assert_eq!(average(&[1, 2, 2]), Some(5.0 / 3.0))));
println!();
println!("2. Which ones cannot fail, and why");
println!(" (d) is the tautology: 1, 1, 1 averages to 1 for any function that");
println!(" divides a sum by a count, and also for one that just returns its");
println!(" first element, and also for one that returns the maximum. An");
println!(" input whose every plausible answer is the same number tests the");
println!(" arithmetic, not the definition.");
println!(" (e) passes for a different reason: both sides compute 5.0/3.0 the");
println!(" same way, in the same rounding mode, so the bits match exactly.");
println!(" Write the literal 1.6666666666666667 instead and it still passes;");
println!(" write 1.666666666666667 and it does not. That is a test of your");
println!(" typing, not of the code.");
println!();
println!("3. The float assertion that means something");
let a = average(&[1, 2, 2]).unwrap();
println!(" (a - 5.0/3.0).abs() < 1e-12 -> {}",
message_from(move || assert!((a - 5.0 / 3.0).abs() < 1e-12,
"average was {a}, expected about 1.6667")));
println!(" A tolerance says what \"close enough\" means for THIS domain. On a");
println!(" 0-5 ballot, 1e-12 is absurd precision and 0.005 would be honest.");
println!(" Picking the number is the work; == avoids the question.");
println!();
println!("4. What a failure actually prints");
println!(" {}", message_from(|| assert_eq!(average(&[5, 3, 4]), Some(3.0))));
println!(" ...then two lines naming left and right. assert! would have said");
println!(" only \"assertion failed: average(&[5, 3, 4]) == Some(3.0)\" — true,");
println!(" unhelpful, and one debugging session longer.");
println!();
println!("5. The message, and when to spend one");
println!(" {}", message_from(|| {
let seats = 3;
let candidates = 2;
assert!(seats <= candidates,
"cannot fill {seats} seats from {candidates} candidates");
}));
println!(" Values alone would have printed `3` and `2` with no clue which is");
println!(" which. The message is a format! run only on failure, so it costs");
println!(" nothing on the happy path — spend one whenever the two numbers do");
println!(" not name themselves.");
}
Verified output of what_a_test_asserts_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. The five assertions, and what each one proves
a. average(&[5, 3, 4]) is Some(4.0) -> PASSED
b. average(&[]) is None -> PASSED
c. average(&[1, 2]) == Some(1.5) -> PASSED
d. average(&[1, 1, 1]) == Some(1.0) -> PASSED
e. average(&[1, 2, 2]) == Some(5.0/3.0) -> PASSED
2. Which ones cannot fail, and why
(d) is the tautology: 1, 1, 1 averages to 1 for any function that
divides a sum by a count, and also for one that just returns its
first element, and also for one that returns the maximum. An
input whose every plausible answer is the same number tests the
arithmetic, not the definition.
(e) passes for a different reason: both sides compute 5.0/3.0 the
same way, in the same rounding mode, so the bits match exactly.
Write the literal 1.6666666666666667 instead and it still passes;
write 1.666666666666667 and it does not. That is a test of your
typing, not of the code.
3. The float assertion that means something
(a - 5.0/3.0).abs() < 1e-12 -> PASSED
A tolerance says what "close enough" means for THIS domain. On a
0-5 ballot, 1e-12 is absurd precision and 0.005 would be honest.
Picking the number is the work; == avoids the question.
4. What a failure actually prints
assertion `left == right` failed
...then two lines naming left and right. assert! would have said
only "assertion failed: average(&[5, 3, 4]) == Some(3.0)" — true,
unhelpful, and one debugging session longer.
5. The message, and when to spend one
cannot fill 3 seats from 2 candidates
Values alone would have printed `3` and `2` with no clue which is
which. The message is a format! run only on failure, so it costs
nothing on the happy path — spend one whenever the two numbers do
not name themselves.
See also¶
- Where a test goes — the module it lives in, and what that decides
- The example that is a test — the assertions that are also documentation
- What a panic costs — the mechanism a failing test is built on
- What a float actually stores — why the two float lines above disagree
- cargo-nextest — one process per test, and what that buys
Sources¶
Unit testing ↗ in Rust by Example, and std::assert_eq ↗. Every transcript on this page was captured by running the failing assertion under catch_unwind in the example program, so the messages are rustc's own.
Po polsku¶
W Ruscie nie ma osobnej biblioteki asercji do nauczenia się — test to funkcja, która panikuje, gdy jest niezadowolona. Kto przychodzi z JUnita albo z pytest, szuka warstwy, której tu nie ma, i to dobra wiadomość: assert_eq!, assert! i panic! wyczerpują temat.
Reguła praktyczna: assert_eq! zawsze, gdy obie strony są wartościami, bo przy niepowodzeniu wypisuje obie i od razu widać, czym się różnią. assert!(a == b) powie tylko tyle, że warunek jest fałszywy, więc zaczynasz debugować sam test zamiast kod.
Pułapka jest jedna i dotyczy nawyku, nie składni: asercja, która nie może zawieść, jest gorsza niż jej brak — daje zielone światło i zero informacji. Podręcznikowy przypadek to porównywanie liczb zmiennoprzecinkowych przez ==. 0.1 + 0.2 == 0.3 jest fałszem, więc albo test pęka bez powodu, albo ktoś go „naprawia" tak, że przestaje cokolwiek sprawdzać; dla f64 porównuje się różnicę z przyjętą tolerancją.
Szukaj po polsku: testy jednostkowe w Ruscie · asercje · porównywanie liczb zmiennoprzecinkowych · rust assert_eq · rust float epsilon comparison