Course outline · 0% complete

0/29 lessons0%

Course overview →

Failure Messages That Explain

lesson 2-3 · ~9 min · 6/29

Make the failure do the talking

On a team, most test failures are read far from the code that caused them, in a terminal scrollback or an automated test report, often by someone who did not write the test. A bare assert got == 6 that fails tells that reader almost nothing, just AssertionError and a line number.

Python lets you attach a message that travels with the failure:

assert got == 6, f"expected 6, got {got}"

The message only appears when the assert fails, so there is no cost when things are green. Write it for the future reader who sees the failure without the context you have right now, which means naming the call, showing the actual value, and showing the expected value.

One formatting tool helps a lot. Writing {got!r} inside an f-string prints the value's repr, so strings keep their quotes. Seeing 'hello-world' with quotes is much clearer in a message than bare hello-world, and it is the only way to tell an empty string from a missing value or the number 5 from the string '5'.

A message that names the call and both values

The assert is wrong on purpose so the message can be seen. The AssertionError is caught and printed, the same way the lesson 1-3 runner would.

def average(nums):
    return sum(nums) / len(nums)

got = average([2, 4, 9])
try:
    assert got == 6, f"average([2, 4, 9]) returned {got}, expected 6"
except AssertionError as e:
    print("FAIL:", e)

Output

FAIL: average([2, 4, 9]) returned 5.0, expected 6

The message contains three things, and each one earns its place. The call is spelled out so the reader knows what was tried, the actual value says what happened, and the expected value says what should have happened.

In this case the message settles the argument immediately. The average of 2, 4, and 9 is 5, so the function is right and the assert was wrong, which is a genuinely common outcome when tests are written from a misremembered expectation.

Printing the caught exception with print("FAIL:", e) shows only the message, not a traceback. That is what a test runner does, and unit 8 covers reading the full traceback when you need the line numbers too.

A message that hands you the hypothesis

This failure looks impossible at first, since 0.29 times 100 should be 29. The message is what makes the cause visible.

def to_cents(price):
    return int(price * 100)

got = to_cents(0.29)
try:
    assert got == 29, f"to_cents(0.29) returned {got}, expected 29"
except AssertionError as e:
    print("FAIL:", e)

Output

FAIL: to_cents(0.29) returned 28, expected 29

Floating-point numbers are stored in binary and 0.29 has no exact binary representation, so 0.29 * 100 lands at 28.999999999999996 rather than 29. int() then truncates toward zero rather than rounding, which turns that near-29 into 28.

Without the message you would stare at the formula looking for a typo. With it, the actual value of 28 points at truncation directly, which is the difference between a puzzle and a diagnosis.

Two things are worth taking away. Printing 0.29 * 100 on its own is the fastest way to confirm the hypothesis, which is the checkpoint technique from lesson 7-3. And the real fix in money code is round(price * 100), or better, doing all arithmetic in integer cents from the start, so no float ever holds a price.

When the message is evaluated

The message in assert cond, message is evaluated and shown only when cond is false, as part of the AssertionError that gets raised.

It rides along with the exception, so it appears exactly when you need it and never otherwise. Green runs stay completely silent, which means a rich message costs nothing at runtime and there is no reason to leave a bare assert in a test you care about.

The lazy evaluation has a practical consequence worth knowing. Because the f-string is only built on failure, it is safe to put expensive or verbose formatting in there:

assert result == expected, f"mismatch:\n  got:      {result!r}\n  expected: {expected!r}"

That multi-line message would be wasteful if it were built on every passing run, and it is not, so a long and detailed message is as cheap as a short one.

One trap to avoid: assert (cond, message) with parentheses around both is a single tuple, and a non-empty tuple is always truthy, so that assert can never fail. Some linters catch it, and knowing the shape is the reliable defense.

Adding a message with repr formatting

The assert below fails with no explanation. Adding a message turns it into a report that names the call and shows both strings with their quotes.

def slugify(title):
    return title.lower().replace(" ", "-")

got = slugify("Hello World")
try:
    assert got == "helloworld", f"slugify('Hello World') returned {got!r}, expected 'helloworld'"
except AssertionError as e:
    print("FAIL:", e)

Output

FAIL: slugify('Hello World') returned 'hello-world', expected 'helloworld'

The syntax is a comma after the condition followed by the message, and an f-string is the usual choice so values can be interpolated.

{got!r} prints 'hello-world' with quotes, while plain {got} would drop them. In this specific message the quotes are what make the difference visible, since the expected value is spelled with quotes too and comparing hello-world against 'helloworld' across different formatting is needlessly hard.

The failure here is arguably the test's fault rather than the function's, since a slug conventionally does use hyphens. That ambiguity is worth sitting with, because a good message lets the reader make that judgment, while a bare AssertionError forces them to reconstruct both values before they can even start.