v0.2.0

Failure messages

The message is the product. This page is the grammar every message follows, and the rules behind it — useful when you are predicting what a failure will say, and required reading when you are writing your own assertions.

5 min readEdit this page

The grammar

Every failure sentence in the library is assembled in exactly one place, from one template:

Expected {subject} {expectation}[ because {reason}].
[optional detail block]

Four parts, two of them optional — the brackets in the template say which:

python
from lovely_assertions import expect, AssertionFailure

daily_totals = [10, 20, 31]
try:
    expect(daily_totals).is_equal_to([10, 20, 30], because="the ledger is authoritative")
except AssertionFailure as failure:
    print(failure)
Expected daily_totals to equal [10, 20, 30], but was [10, 20, 31] because the ledger is authoritative.
  first difference at index 2: 31 instead of 30
Part Here Comes from
subject daily_totals recovered from your source, or name= / described_as; prefixed with the scope's path inside a named soft scope
expectation to equal [10, 20, 30], but was [10, 20, 31] the assertion
reason because the ledger is authoritative your because=
detail block first difference at index 2: … the difference engine, when there is more to say

The reason ends the first sentence; the detail block follows it. That ordering is deliberate — appending your reason after a multi-line diff would leave it dangling off the last line of the diff, where nobody reads it.

Rule 1 — say what was expected and what was there

Half a message is one that tells you what you asked for and makes you go and find out what you got:

Expected server_config to contain key 'hostname'.

The whole one does both, and volunteers the near miss:

python
from lovely_assertions import expect, AssertionFailure

server_config = {"host": "db-01", "port": 5432}
try:
    expect(server_config).contains_key("hostname")
except AssertionFailure as failure:
    print(failure)
Expected server_config to contain key 'hostname' (did you mean 'host'?), but the keys were ['host', 'port'].

The reader should not have to re-run anything to understand a failure.

Rule 2 — distinguish failures that look alike

This is where the library earns its place over a diff. Two failures that a == comparison renders almost identically are different bugs with different fixes, so they get different sentences:

python
from lovely_assertions import expect, AssertionFailure

server_config = {"port": 8080}

try:
    expect(server_config).contains_entry("port", 9090)
except AssertionFailure as failure:
    print(failure)

try:
    expect(server_config).contains_entry("timeout", 30)
except AssertionFailure as failure:
    print(failure)
Expected server_config to contain entry 'port': 9090, but that key held 8080.
Expected server_config to contain entry 'timeout': 30, but the key was missing; the keys were ['port'].

The key holds a different value and the key is missing are two sentences because they send you to two different places. When an assertion cannot tell two causes apart, that is a reason to reconsider the assertion — not to write a vaguer message.

Rule 3 — bounded per value and per level

A message nobody reads is not a message. Every value is capped, and so is every level of a difference: comparing two five-thousand-element lists costs you a few hundred characters rather than sixty thousand. Controlling output has what the bounds are and how to raise them for a block.

python
from lovely_assertions import expect, AssertionFailure

audit_rows = list(range(5000))
try:
    expect(audit_rows).is_equal_to(list(range(4999)))
except AssertionFailure as failure:
    print(len(str(failure)) < 500)
True

The bounds are per part, and not on the message as a whole. A difference through nested structure can show max_items entries at each of max_depth + 1 levels, which multiplies out, and the report a soft scope raises prints every failure it collected, uncapped. The shape above — one flat value against another — is the one this rule holds for outright.

Rule 4 — the expectation is a sentence fragment

An assertion never writes a whole sentence. It writes the middle of one:

"to be sorted, but 1 at index 1 came after 3: [3, 1, 2]"

Lower case, no leading Expected, no trailing full stop. The one place that assembles messages adds the subject in front and the reason and the stop behind. This is what makes subject naming, because= and soft scopes — a block that collects failures instead of stopping at the first — work for every assertion without any of them wiring it up, including yours.

Rule 5 — values render through the registry

Values inside a message go through the formatter registry rather than a raw repr at the call site. A formatter is any object with two methods — can_handle(value) says whether it claims the value, format(value) renders it — and the first to claim a value wins, so registering one teaches every message that renders that type how it reads. format receives an object, which is why the example narrows it with an assert for the type checker:

python
from lovely_assertions import expect, register_formatter, AssertionFailure


class Money:
    def __init__(self, cents: int) -> None:
        self.cents = cents


class MoneyFormatter:
    def can_handle(self, value: object, /) -> bool:
        return isinstance(value, Money)

    def format(self, value: object, /) -> str:
        assert isinstance(value, Money)
        return f"GBP {value.cents / 100:.2f}"


register_formatter(MoneyFormatter())

price = Money(1250)
try:
    expect(price).is_equal_to(Money(999))
except AssertionFailure as failure:
    print(failure)
Expected price to equal GBP 9.99, but was GBP 12.50.

Without the formatter, both sides of that message would have been a memory address.

One limit, and it is not obvious. A message that renders your values one at a time — contains, a keys listing, the difference block under the sentence — asks the registry for each of them. A message that renders a container whole falls back to the container's repr, which calls each item's __repr__ directly and never reaches your formatter: expect([price]).is_equal_to([Money(999)]) puts the addresses back in the first line, and reads as money only in the difference below it. Register a formatter for the container type too, or assert on the items.

See Controlling output.

Where the subject name comes from

The subject name is recovered from your source at failure time, and falls back to the value when it cannot be recovered unambiguously — Reading a failure has the mechanism, when it gives up, and the two ways to name a subject yourself. Nothing about it runs when an assertion passes: the library imports neither ast nor linecache until a failure happens, and Performance shows how to check that for yourself.

Why not just a diff?

Because a diff is the answer to "what is different", and the question a failing test asks is "what is wrong". Those coincide when the two values are scalars, and diverge as soon as they are not. This library is the bet that the second question is the one worth answering, and every rule above follows from it.

esc

Nothing matches that. The reference is generated, so try an assertion name.

No results. The reference is generated, so try an assertion name.

↑↓ navigate↵ open30 pages, every example executed in CI