Skip to content

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.

#[test]
fn totals_a_ballot() {
    assert_eq!(tally(&[5, 3, 0]), 8);
}

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

assert!(seats <= candidates, "cannot fill {seats} seats from {candidates} candidates");

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

assert_eq!(average(&[1, 1, 1]), Some(1.0));

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 == y is the same statement, and pytest's rewriting is what gives you the left/right output that assert_eq! gives here — so the ergonomics land in the same place by different routes. Two differences. Python's bare assert is removed under -O, so it is unsafe for anything but tests; Rust's assert! is always compiled (debug_assert! is the one that vanishes in release). And there is no unittest-style self.assertEqual vocabulary 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.approx is 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 = … ) is assert_eq! with the arguments named, and the msg parameter is the message above. The habits transfer almost completely: assert_equals over assert_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_equals on a packed number with tol is the float tolerance argument, and the ABAP habit of asserting sy-subrc is the thing Result makes unnecessary here. What Rust does not have is RISK LEVEL / DURATION annotations; #[ignore] is the whole of that vocabulary.
  • Java / C#. JUnit's assertEquals(expected, actual) — note the argument order is the opposite of assert_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

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