Chapter 02

assert and reading failure reports

How pytest sees inside a plain assert, how to read a failure report line by line, and which comparison to write so the report tells you the most.

45 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

Here is a check written in plain Python, in a file called check.py:

python
def full_name(first, last):
    return f"{first} {last}".title()


assert full_name("ada", "lovelace") == "Ada Lovelace "
text
Traceback (most recent call last):
  File "/home/you/shop/check.py", line 5, in <module>
    assert full_name("ada", "lovelace") == "Ada Lovelace "
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

Python tells you the claim was false, and nothing else. What did full_name actually return? You would have to add a print, run it again, and look.

Put the same line in a test, test_names.py, and run pytest -q:

python
from names import full_name


def test_full_name():
    assert full_name("ada", "lovelace") == "Ada Lovelace "
text
F                                                                        [100%]
=================================== FAILURES ===================================
________________________________ test_full_name ________________________________

    def test_full_name():
>       assert full_name("ada", "lovelace") == "Ada Lovelace "
E       AssertionError: assert 'Ada Lovelace' == 'Ada Lovelace '
E         
E         - Ada Lovelace 
E         ?             -
E         + Ada Lovelace

test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError: assert 'Ada Lovelace' ...
1 failed in 0.01s

Now you can see both values, and a marker under the one character that differs: a trailing space in the expected string. The function was right; the test was wrong.

A failing test is only as useful as what it tells you. This chapter is about reading everything pytest tells you, and about writing asserts that give it something worth saying.

By the end of this chapter you can

  • Explain what assertion rewriting is and where it does and does not apply
  • Read a failure report line by line: >, E, where, file:line and the short summary
  • Choose between ==, in, is None, is True and a bare assert x
  • Read list, dict and string diffs, and ask for the full diff with -vv
  • Add a message to an assert, and avoid the tuple that is always true
  • Shape a report with --tb=short, --tb=line, --tb=no and -l

Prerequisites: installing pytest and your first test.


Before you write the test

An assert is a promise written down. Before you type one, three questions need answers, and none of them involve pytest.

What exactly is promised? Take the function this chapter finishes with, summarise(lines), which reads invoice lines like "pen, 12, 15.0". Its contract in one sentence: it skips blank lines and returns a dict with the number of items, their names in the order given, and the total rounded to two places. Every assert you write later is one clause of that sentence. If you cannot say the sentence, you are not ready to write the test — you would end up testing whatever the code happens to do.

What must be in place? Very little for this chapter: the virtual environment from chapter one with pytest installed (python -m pytest --version answers), and the module under test importable from where you run pytest — here invoice.py sits next to test_invoice.py and you run pytest from that folder. No files, no network, no fixtures.

Which cases, and what does each expect? Work the expected values out by hand, before running the code. 12 × 15.0 + 2 × 850.0 is 1880.0; that number comes from arithmetic, not from what summarise printed. If you copy the output into the test instead, the test records today's behaviour, bugs included, and will defend the bug from then on.

| Case | Input | Expected | Assert you will write | | --- | --- | --- | --- | | One line, spaces around the name | " pen , 12, 15.0" | {"name": "pen", "qty": 12, "price": 15.0} | == on the whole dict | | Blank lines are skipped | ["pen, 12, 15.0", "", "bag, 2, 850.0"] | count is 2 | == | | The whole summary | same three lines | {"count": 2, "names": ["pen", "bag"], "total": 1880.0} | == on the whole dict | | Looking up an item that exists | "pen" | a record, not None | is not None | | Looking up an item that does not | empty list, "pen" | None | is None | | The total makes sense | same three lines | greater than 0 | > with a message |

The last column is what this chapter is about: for each promise, the comparison that states it most precisely and fails with the most useful report.

What not to test. That str.split splits, that round rounds, that int("12") is 12 — Python already tests those. The exact wording of pytest's output is not yours to test either. A malformed line such as "pen, twelve, 15.0" belongs in the plan, but asserting that an error is raised needs pytest.raises, which is chapter four; write the row down now and the test then.


How pytest sees inside an assert

Plain Python throws the values away the moment an assert fails. Pytest keeps them because it changes your test file before running it.

When pytest imports a test module, it finds every assert statement and rewrites it into code that stores each intermediate value — the result of full_name(...), the string on the right — before checking the condition. When the check fails, those stored values become the report. This is assertion rewriting, and it is why pytest needs no assertEqual or assertIn: an ordinary assert with an ordinary == is enough.

Switch it off to see what it does for you. pytest -q --assert=plain on the same test:

text
def test_full_name():
>       assert full_name("ada", "lovelace") == "Ada Lovelace "
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError

test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError

Back to one bare word. Rewriting applies to test files (test_*.py, *_test.py) and to conftest.py. It does not apply to your other modules, which matters as soon as you put an assert in a helper — "When it breaks" shows what that looks like.

Reading a failure report, line by line

A small module, cart.py:

python
def subtotal(items):
    return sum(price * qty for price, qty in items)


def total(items, discount):
    return subtotal(items) - discount

And test_cart.py, where the second test expects the wrong number:

python
from cart import subtotal, total


def test_subtotal():
    assert subtotal([(15.0, 2), (60.0, 1)]) == 90.0


def test_total_with_discount():
    items = [(15.0, 2), (60.0, 1)]
    assert total(items, 10) == 70.0

Run plain pytest:

text
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 2 items

test_cart.py .F                                                          [100%]

=================================== FAILURES ===================================
___________________________ test_total_with_discount ___________________________

    def test_total_with_discount():
        items = [(15.0, 2), (60.0, 1)]
>       assert total(items, 10) == 70.0
E       assert 80.0 == 70.0
E        +  where 80.0 = total([(15.0, 2), (60.0, 1)], 10)

test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
========================= 1 failed, 1 passed in 0.01s ==========================

Read it from the top:

  • test_cart.py .F — one character per test, in order. . passed, F failed.
  • ____ test_total_with_discount ____ — the heading of one failure. Each failing test gets its own section.
  • The source lines — the test body up to the line that failed.
  • > — the exact line that raised. Everything above it ran without complaint.
  • E assert 80.0 == 70.0 — the assert again, with each expression replaced by its value. The left side was 80.0, the right side 70.0.
  • E + where 80.0 = total(...) — where a value came from. For a function call, pytest shows the call with its arguments filled in. Nested calls give nested where lines, each indented further.
  • test_cart.py:10: AssertionError — file and line, ready to jump to in your editor, and the kind of exception.
  • short test summary info — one line per failure, FAILED path::test_name - first line of the error. With many failures, read this list first.
  • The last line — the counts and the time.

Why read in this order? Because each line narrows the search. The summary tells you which tests; the > line which claim; the E line which side is off; the where line which call produced it. Only then do you open the code — and you open it at the right function.

Sometimes the first E line reads AssertionError: assert ... and sometimes just assert .... It is the same failure; the prefix stays when the values contain quote characters.

The comparisons you will write most

Any expression can follow assert, and pytest explains the common ones. users.py:

python
def find_user(users, email):
    for user in users:
        if user["email"] == email:
            return user
    return None


def active_emails(users):
    return [u["email"] for u in users if u["active"]]

Four tests, four kinds of comparison:

python
from users import active_emails, find_user

ADA = {"email": "ada@example.com", "active": True}
ALAN = {"email": "alan@example.com", "active": False}


def test_equal():
    assert active_emails([ADA, ALAN]) == ["ada@example.com", "alan@example.com"]


def test_in():
    assert "alan@example.com" in active_emails([ADA, ALAN])


def test_is_not_none():
    assert find_user([ADA], "ADA@example.com") is not None


def test_greater():
    assert len(active_emails([ADA, ALAN])) > 1

All four fail. The > and E lines of each, cut from one pytest -q run:

text
>       assert active_emails([ADA, ALAN]) == ["ada@example.com", "alan@example.com"]
E       AssertionError: assert ['ada@example.com'] == ['ada@example...@example.com']
E         
E         Right contains one more item: 'alan@example.com'
E         Use -v to get more diff

>       assert "alan@example.com" in active_emails([ADA, ALAN])
E       AssertionError: assert 'alan@example.com' in ['ada@example.com']
E        +  where ['ada@example.com'] = active_emails([{'email': 'ada@example.com', 'active': True}, {'email': 'alan@example.com', 'active': False}])

>       assert find_user([ADA], "ADA@example.com") is not None
E       AssertionError: assert None is not None
E        +  where None = find_user([{'email': 'ada@example.com', 'active': True}], 'ADA@example.com')

>       assert len(active_emails([ADA, ALAN])) > 1
E       AssertionError: assert 1 > 1
E        +  where 1 = len(['ada@example.com'])
E        +    where ['ada@example.com'] = active_emails([{'email': 'ada@example.com', 'active': True}, {'email': 'alan@example.com', 'active': False}])

Each says something different:

  • == compares values. For lists, pytest also says how they differ — the right side has one more item. (active_emails is right to leave Alan out; this test's expectation was wrong.)
  • in checks membership, and shows the whole container so you can see what was there instead.
  • is None / is not None checks identity with the one None object. find_user returned None because the lookup is case-sensitive.
  • <, >, <=, >= work as written. Two where lines: 1 came from len(...), and that list from active_emails(...).

Why does the choice matter, if every form can be made to fail? Because the report is built from the expression you wrote. The wrong way to ask "is Alan in the list?":

python
def test_count():
    emails = ["ada@example.com"]
    assert emails.count("alan@example.com") > 0
text
>       assert emails.count("alan@example.com") > 0
E       AssertionError: assert 0 > 0
E        +  where 0 = <built-in method count of list object at 0x7d9189cec640>('alan@example.com')
E        +    where <built-in method count of list object at 0x7d9189cec640> = ['ada@example.com'].count

The headline is assert 0 > 0, and the list you care about is buried at the end of the second where. The right way, assert "alan@example.com" in emails, puts the list in the headline. Write the assert as the sentence you had in mind, and pytest explains the failure in the same terms.

Truthiness: assert x is not assert x == True

assert x passes whenever x is truthy — anything except False, None, 0, "" and empty containers. Often that is exactly what you want; sometimes it hides a bug.

rules.py has two functions. overdue is correct; is_adult has a bug — it returns strings instead of booleans:

python
def overdue(invoices):
    return [i for i in invoices if i["days"] > 30]


def is_adult(age):
    if age >= 18:
        return "yes"
    return "no"
python
from rules import is_adult, overdue


def test_finds_overdue():
    assert overdue([{"id": 1, "days": 12}, {"id": 2, "days": 30}])


def test_child_is_not_adult():
    assert not is_adult(12)


def test_adult_truthy():
    assert is_adult(40)


def test_adult_is_true():
    assert is_adult(40) is True

pytest -q, with the three failures cut down to their E lines:

text
FF.F                                                                     [100%]
E       AssertionError: assert []
E        +  where [] = overdue([{'id': 1, 'days': 12}, {'id': 2, 'days': 30}])
E       AssertionError: assert not 'no'
E        +  where 'no' = is_adult(12)
E       AssertionError: assert 'yes' is True
E        +  where 'yes' = is_adult(40)
=========================== short test summary info ============================
FAILED test_rules.py::test_finds_overdue - AssertionError: assert []
FAILED test_rules.py::test_child_is_not_adult - AssertionError: assert not 'no'
FAILED test_rules.py::test_adult_is_true - AssertionError: assert 'yes' is True
3 failed, 1 passed in 0.01s

The first failure: even a bare assert some_list gets a useful report. assert [] says the list came back empty, not wrong. Thirty days is not more than thirty, so the code is right and the test's data was wrong — learned from one line.

Now the dot in FF.F. test_adult_truthy passed. is_adult returns the string "yes", which is truthy, so assert is_adult(40) was satisfied by a buggy function. "no" is truthy too, which is why assert not is_adult(12) caught it — but only by luck of which case you happened to test.

So, assert x == True? It catches "yes", but it has a hole of its own: in Python 1 == True and 0 == False. stock.py:

python
def in_stock(qty):
    return qty and qty > 0
python
from stock import in_stock


def test_zero_equals_false():
    assert in_stock(0) == False


def test_zero_is_false():
    assert in_stock(0) is False
text
.F                                                                       [100%]
>       assert in_stock(0) is False
E       assert 0 is False
E        +  where 0 = in_stock(0)
1 failed, 1 passed in 0.01s

qty and qty > 0 returns 0 when qty is 0, not False. The == test passed; the is test noticed. The rule that follows:

  • assert x / assert not x when any truthy or falsy value is acceptable — "the list is not empty", "there is no error message".
  • assert x is True / assert x is False when the function promises a real bool.
  • assert x == True almost never. It is looser than is True and says less than a bare assert x; linters flag it.

When the values are big: diffs

For short values, assert 80.0 == 70.0 is the whole story. For a list of forty items or a three-line email, pytest compares the two sides and shows only what differs.

python
def test_list():
    got = ["pen", "notebook", "bag", "eraser"]
    assert got == ["pen", "notebook", "ruler", "eraser"]


def test_dict():
    got = {"id": 7, "name": "Ada", "role": "admin", "active": True}
    assert got == {"id": 7, "name": "Ada", "role": "editor", "active": True}


def test_multiline_string():
    got = "Dear Ada,\nYour order 7 has shipped.\nThanks!"
    assert got == "Dear Ada,\nYour order 7 has been shipped.\nThanks!"

The E lines of the three failures:

text
E       AssertionError: assert ['pen', 'note...ag', 'eraser'] == ['pen', 'note...er', 'eraser']
E         
E         At index 2 diff: 'bag' != 'ruler'
E         Use -v to get more diff

E       AssertionError: assert {'id': 7, 'na...active': True} == {'id': 7, 'na...active': True}
E         
E         Omitting 3 identical items, use -vv to show
E         Differing items:
E         {'role': 'admin'} != {'role': 'editor'}
E         Use -v to get more diff

E       AssertionError: assert 'Dear Ada,\nY...ped.\nThanks!' == 'Dear Ada,\nY...ped.\nThanks!'
E         
E           Dear Ada,
E         - Your order 7 has been shipped.
E         ?                 -----
E         + Your order 7 has shipped.
E           Thanks!

The first E line is shortened with ... to fit; the lines under it carry the detail:

  • Lists — the first index where they differ, or which side has extra items.
  • Dicts — the keys whose values differ. Identical keys are counted, not printed.
  • Strings — a line-by-line diff. Lines starting with two spaces are the same on both sides. + marks the left side of ==, - the right. The ? line underneath points at the exact characters.

So if you always write assert actual == expected, + is what your code produced and - is what you wanted. Mix the order between tests and you will have to stop and work out which is which every time; keep it, and you never do.

A long single-line string gets the same treatment, with the identical part skipped:

python
def test_receipt_line():
    got = "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2420.00"
    assert got == "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2402.00"
text
E       AssertionError: assert '2026-10-07 |...total 2420.00' == '2026-10-07 |...total 2402.00'
E         
E         Skipping 66 identical leading characters in diff, use -v to show
E         - | total 2402.00
E         ?            -
E         + | total 2420.00
E         ?           +

Two transposed digits at the end of an 80-character line, found for you.

-v and -vv: asking for the whole diff

Notice the hints: Use -v to get more diff, use -vv to show. By default pytest truncates long explanations, and each -v raises the verbosity. The dict test with pytest -vv:

text
E       AssertionError: assert {'id': 7, 'name': 'Ada', 'role': 'admin', 'active': True} == {'id': 7, 'name': 'Ada', 'role': 'editor', 'active': True}
E         
E         Common items:
E         {'active': True, 'id': 7, 'name': 'Ada'}
E         Differing items:
E         {'role': 'admin'} != {'role': 'editor'}
E         
E         Full diff:
E           {
E               'id': 7,
E               'name': 'Ada',
E         -     'role': 'editor',
E         ?              ^  ^^^
E         +     'role': 'admin',
E         ?              ^ + ^
E               'active': True,
E           }

Nothing is shortened: both dicts in full, the common items, and a diff with one key per line. Why not use -vv always? Because on a list of two hundred records the full diff is two hundred lines, and the default summary — "at index 2" — is what you needed. Start with the default; add -vv for the one test whose summary is not enough.

Your own message: assert cond, "msg"

After a comma, an assert can take a message, which pytest prints above its own explanation:

python
def test_everything_in_stock():
    stock = {"pen": 12, "bag": 0}
    for name, qty in stock.items():
        assert qty > 0, f"{name} is out of stock"
text
>           assert qty > 0, f"{name} is out of stock"
E           AssertionError: bag is out of stock
E           assert 0 > 0

Without the message you would see assert 0 > 0 and have to work out which item was zero. A message earns its place when the values alone do not say which case failed or why the rule exists. The wrong use is repeating what pytest already shows: "expected 70.0 but got 80.0" adds nothing to assert 80.0 == 70.0.

The tuple trap

When a message makes the line long, it is tempting to wrap the whole thing in parentheses:

python
def test_total():
    total = 80.0
    assert (total == 70.0, "total is wrong")
text
.                                                                        [100%]
=============================== warnings summary ===============================
test_trap.py:3
  /home/you/shop/test_trap.py:3: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
    assert (total == 70.0, "total is wrong")

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01s

The test passed, though total is 80.0. Parentheses with a comma inside build a tuple of two items, and a non-empty tuple is always truthy — the assert checks the tuple, not the comparison. Pytest warns, but a warning at the bottom of a long run is easy to miss. Remove the parentheses:

python
def test_total():
    total = 80.0
    assert total == 70.0, "total is wrong"
text
>       assert total == 70.0, "total is wrong"
E       AssertionError: total is wrong
E       assert 80.0 == 70.0

If the line is too long, put parentheses around the message only:

python
def test_total():
    total = 80.0
    assert total == 70.0, (
        "total is wrong after applying the loyalty discount"
    )

One assert per behaviour

A test that checks fields one after another stops at the first that fails. orders.py has two bugs — the name is not stripped and the quantity stays a string:

python
def parse_order(line):
    order_id, customer, qty, price = line.split(",")
    return {"id": int(order_id), "customer": customer, "qty": qty, "price": float(price)}

The wrong way, then the right way:

python
from orders import parse_order


def test_parse_order_fields():
    order = parse_order("1042, Ada ,3,15.0")
    assert order["id"] == 1042
    assert order["customer"] == "Ada"
    assert order["qty"] == 3
    assert order["price"] == 15.0


def test_parse_order():
    order = parse_order("1042, Ada ,3,15.0")
    assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}
text
>       assert order["customer"] == "Ada"
E       AssertionError: assert ' Ada ' == 'Ada'
E         
E         - Ada
E         +  Ada 
E         ? +   +

>       assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}
E       AssertionError: assert {'id': 1042, ...'price': 15.0} == {'id': 1042, ...'price': 15.0}
E         
E         Omitting 2 identical items, use -vv to show
E         Differing items:
E         {'customer': ' Ada '} != {'customer': 'Ada'}
E         {'qty': '3'} != {'qty': 3}
E         Use -v to get more diff

The first test reports one bug. You fix it, run again, and only then meet the second. The second test compares the whole result and reports both at once.

The guideline is one behaviour per test, not one assert per test. "Parsing a line produces this record" is one behaviour, so one whole-value comparison is ideal. Several asserts are fine when together they describe one behaviour. What to avoid is one test that checks parsing, then skipping blank lines, then totalling — three behaviours, where the first failure hides the other two and the test's name cannot say what broke.

How much report: --tb and -l

The part between the heading and file:line is the traceback, and --tb chooses how much of it to show. The cart.py test with pytest -q --tb=short:

text
___________________________ test_total_with_discount ___________________________
test_cart.py:10: in test_total_with_discount
    assert total(items, 10) == 70.0
E   assert 80.0 == 70.0
E    +  where 80.0 = total([(15.0, 2), (60.0, 1)], 10)

Only the failing line, not the whole function. --tb=line gives one location per failure:

text
E   assert 80.0 == 70.0
     +  where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
/home/you/shop/test_cart.py:10: assert 80.0 == 70.0

And --tb=no leaves only the summary:

text
.F                                                                       [100%]
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
1 failed, 1 passed in 0.01s

The default, --tb=auto, is the long form you have seen all chapter. Use --tb=no or --tb=line to see what failed across a big run; then rerun one test with the default to see why.

Going the other way, -l (--showlocals) adds the local variables of the failing function. pytest -q -l:

text
>       assert total(items, 10) == 70.0
E       assert 80.0 == 70.0
E        +  where 80.0 = total([(15.0, 2), (60.0, 1)], 10)

items      = [(15.0, 2), (60.0, 1)]

test_cart.py:10: AssertionError

In a test with a loop or several intermediate variables, that extra block is often the one that explains the failure — you will see it in the solution at the end.


A complete example

invoice.py reads lines like "pen, 12, 15.0":

python
def parse_line(line):
    name, qty, price = line.split(",")
    return {"name": name.strip(), "qty": int(qty), "price": float(price)}


def summarise(lines):
    items = [parse_line(line) for line in lines if line.strip()]
    total = sum(item["qty"] * item["price"] for item in items)
    return {
        "count": len(items),
        "names": [item["name"] for item in items],
        "total": round(total, 2),
    }


def find_item(items, name):
    for item in items:
        if item["name"] == name:
            return item
    return None

test_invoice.py is the plan from "Before you write the test", one row per test, each written with the comparison from the table's last column:

python
from invoice import find_item, parse_line, summarise

LINES = ["pen, 12, 15.0", "", "bag, 2, 850.0"]


def test_parse_line_strips_and_converts():
    assert parse_line(" pen , 12, 15.0") == {"name": "pen", "qty": 12, "price": 15.0}


def test_blank_lines_are_skipped():
    assert summarise(LINES)["count"] == 2


def test_summary():
    assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}


def test_find_item_present():
    items = [parse_line("pen, 12, 15.0")]
    assert find_item(items, "pen") is not None


def test_find_item_missing():
    assert find_item([], "pen") is None


def test_total_is_positive():
    total = summarise(LINES)["total"]
    assert total > 0, f"total should be positive, got {total}"

pytest -q:

text
......                                                                   [100%]
6 passed in 0.01s

Now someone "tidies" summarise so the names come back sorted, changing one line to "names": sorted(item["name"] for item in items),. pytest -q --tb=short:

text
..F...                                                                   [100%]
=================================== FAILURES ===================================
_________________________________ test_summary _________________________________
test_invoice.py:15: in test_summary
    assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
E   AssertionError: assert {'count': 2, ...otal': 1880.0} == {'count': 2, ...otal': 1880.0}
E     
E     Omitting 2 identical items, use -vv to show
E     Differing items:
E     {'names': ['bag', 'pen']} != {'names': ['pen', 'bag']}
E     Use -v to get more diff
=========================== short test summary info ============================
FAILED test_invoice.py::test_summary - AssertionError: assert {'count': 2, .....
1 failed, 5 passed in 0.01s

Read it the way this chapter taught. One test of six failed, test_summary, at line 15. The first E line is shortened, so the detail is below: two keys matched, and names differs — ['bag', 'pen'] from the code (left) against ['pen', 'bag'] in the test (right). The order changed, nothing else. Whether that is a bug or a deliberate change is now a decision about invoices, not a hunt through code.

Notice too which tests did not fail. The count, the lookups and the total still pass, so the report narrows the change to one key of one dict. That is the payoff of one behaviour per test: the pattern of passes and failures is itself a diagnosis.


When it breaks

E AssertionError with no values, from a helper module Rewriting covers only test files and conftest.py. An assert inside, say, checks.py gives a bare AssertionError. Register the module before it is imported, at the top of conftest.py: pytest.register_assert_rewrite("checks"). The report then shows assert 0 > 0 as usual.

PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses? You wrote assert (condition, "message"). That is a tuple, and the test can never fail. Remove the outer parentheses; wrap only the message if the line is long.

AssertionError: assert None == ... or + where None = ... The function you called returned None. Usually it has a path with no return, or it changes something in place and returns nothing — list.sort() is the classic case.

Use -v to get more diff / ...Full output truncated (8 lines hidden), use '-vv' to show Not an error — pytest shortened the explanation. Rerun that one test with -vv.

A test that passes when the value is obviously wrong Look for assert x where x is a non-empty string like "no", assert x == True with x equal to 1, or the tuple trap. A test that has never failed has never been shown to work — break the code on purpose once and watch it go red.