Assertions and Custom Matchers

8 min read

The plain assert statement is the entire assertion API in pytest — but pytest adds two important things on top: assertion rewriting so the failure message shows the actual values that didn't match, and a small handful of helpers for cases plain assert can't express. This lesson covers those helpers (pytest.raises, pytest.approx, match), the patterns for asserting on collections, building custom assertion helpers that produce useful failure messages, and when soft assertions (pytest-check) earn their keep.

What pytest does to your assert

Plain Python:

assert x == y

…raises AssertionError with no message. pytest rewrites the bytecode of your test files at import time, producing rich failure output:

def test_user_role():
    user = {"name": "Bob", "role": "tester"}
    assert user["role"] == "admin"
>       assert user["role"] == "admin"
E       AssertionError: assert 'tester' == 'admin'
E         - admin
E         + tester

That diff (- admin / + tester) is generated by pytest — no helper, no library. Equality, in, is None, isinstance, </>, all pretty-printed.

The everyday assertion patterns

Most assertions you'll write fall into a small set:

# Equality and inequality
assert status == 200
assert status != 500
 
# Membership
assert "admin" in user["roles"]
assert "@" in email
assert "tester" not in admins
 
# Type checks
assert isinstance(response, dict)
assert isinstance(user["age"], int)
 
# Magnitude
assert len(users) > 0
assert response_time < 2.0
assert 200 <= status < 300                  # chained comparison
 
# None checks
assert user.get("email") is not None
assert user.get("verified_at") is None
 
# Collection-wide checks
assert all(u["active"] for u in users)
assert any(u["role"] == "admin" for u in users)

all() and any() are built-ins that read like English. They're the right shape for "every item must …" and "at least one item must …" assertions; pytest still reports the line that failed, though it can't drill into which item if the generator is opaque. For finer messages, see "custom assertion helpers" below.

Asserting an exception is raised — pytest.raises

Plain assert doesn't help when you want a function to raise. Use pytest.raises as a context manager:

import pytest
from helpers import set_timeout
 
def test_negative_timeout_rejected():
    with pytest.raises(ValueError):
        set_timeout(-1)

The block passes if set_timeout(-1) raises ValueError (or a subclass). Anything else — a different exception, or no exception at all — fails the test.

To assert on the exception's message, pass match= (a regex):

def test_timeout_message():
    with pytest.raises(ValueError, match="must be positive"):
        set_timeout(-1)

If the regex doesn't match, the test fails — even if the right type was raised.

To inspect the exception further, capture it via as:

def test_api_error_carries_status():
    with pytest.raises(ApiError) as exc_info:
        client.fetch("/missing")
    assert exc_info.value.status_code == 404

exc_info.value is the actual exception object; exc_info.type is the class.

Approximate comparisons — pytest.approx

Floats don't equal each other reliably (0.1 + 0.2 == 0.3 is False). Use pytest.approx for tolerant comparisons:

def test_pass_rate():
    rate = compute_pass_rate(passed=28, total=32)
    assert rate == pytest.approx(0.875)            # default tolerance
    assert rate == pytest.approx(0.875, abs=0.01)  # absolute tolerance
    assert rate == pytest.approx(0.875, rel=1e-3)  # relative tolerance

approx works on numbers and on collections of numbers:

def test_distribution():
    counts = compute_counts(...)
    assert counts == pytest.approx({"P0": 5, "P1": 12, "P2": 23}, abs=1)

A common QA case: response-time SLAs that are "around N seconds":

assert response.elapsed.total_seconds() == pytest.approx(1.5, abs=0.5)

— which reads "between 1.0 and 2.0."

Asserting on dicts — equality is your friend

Comparing dicts with == checks every key and value, and pytest's diff is excellent:

def test_user_payload():
    user = build_user("Alice", "alice@test.com")
    assert user == {
        "name": "Alice",
        "email": "alice@test.com",
        "role": "tester",
    }

If user has an extra key, has the wrong value for one, or is missing a key, the diff shows exactly which:

E       AssertionError: assert {...} == {...}
E         Common items:
E         {'name': 'Alice', 'email': 'alice@test.com'}
E         Differing items:
E         Left: {'role': 'admin'}
E         Right: {'role': 'tester'}

For partial matches — "the response has at least these keys" — assert each key separately:

assert user["name"] == "Alice"
assert user["email"] == "alice@test.com"
# ... ignore other keys ...

Custom assertion helpers — readable failure messages

When you write the same shape of assertion in twenty tests, lift it into a helper. Two patterns:

Function with asserts and messages. Each assert carries a string message that pytest prints on failure:

def assert_valid_user(user):
    assert "id" in user, "user is missing 'id'"
    assert "name" in user, "user is missing 'name'"
    assert "email" in user, "user is missing 'email'"
    assert "@" in user["email"], f"invalid email: {user['email']!r}"
    assert user["role"] in ("admin", "tester", "viewer"), \
        f"unexpected role: {user['role']!r}"

Tests then read like English:

def test_create_user_returns_valid_shape():
    user = api_client.create_user("Alice")
    assert_valid_user(user)

When one of the inner asserts fails, pytest shows the failing line and your message. That's much more useful than assert "id" in user alone.

Hide the helper from the traceback. Tracebacks default to showing every frame, which means failures inside helpers can be noisy. Add a magic line at the top of the helper:

def assert_valid_user(user):
    __tracebackhide__ = True            # hides this helper from the failure trace
    assert "id" in user, "user is missing 'id'"
    # ... more asserts ...

Now the failure points at the line in the test that called the helper, not at the helper's internals — much cleaner.

Asserting on lists of items — three approaches

Three patterns you'll reach for, in order of strictness:

# 1. Whole-list equality (strict — order matters)
assert names == ["Alice", "Bob", "Carol"]
 
# 2. Set equality (order doesn't matter)
assert set(names) == {"Alice", "Bob", "Carol"}
 
# 3. Subset / membership
assert "Alice" in names
assert {"Alice", "Bob"}.issubset(names)

Pick the loosest one that still catches the bug. Whole-list equality is fragile under harmless reorderings; set equality survives those but doesn't catch duplicates; membership ignores everything else in the list.

Soft assertions — pytest-check

By default, the first failed assert in a test stops it. Sometimes you want to collect every failure and report them all — checking five fields of a response, for instance, instead of fixing one and re-running. The pytest-check plugin adds soft assertions:

pip install pytest-check
import pytest_check as check
 
def test_user_payload(api_client):
    user = api_client.get("/users/7")
    check.equal(user["name"], "Alice")
    check.equal(user["email"], "alice@test.com")
    check.equal(user["role"], "admin")
    check.is_true(user["is_active"])

If three of the four checks fail, all three are reported in the test's failure message. Use sparingly — soft assertions only really help when the checks are independent (different fields of the same record). For dependent assertions, hard fail-fast is safer.

For Playwright tests there's a similar pattern with expect()'s built-in soft assertions: expect(...).to_have_text(...).

Picking the right shape — three side by side

Three assertion shapes for three jobs

Plain assert

  • assert x == y

  • Use for: equality, type, membership, len, range — anything Python can express directly

  • pytest rewrites to show actual values on failure

  • First failure aborts the test (the right default)

  • No imports, no helpers — your bread and butter

pytest.raises / approx

  • with pytest.raises(SomeError): ...

  • Use for: 'this should raise', floats with tolerance — cases plain assert can't express

  • match='regex' verifies the message

  • approx works on numbers and collections of numbers

  • First-class pytest features — no extra install

Custom helpers

  • def assert_valid_user(u): ...

  • Use for: shape checks repeated across many tests

  • Each inner assert with a message → useful failure output

  • __tracebackhide__ = True keeps failures pointing at the test

  • Build a small library of assert_X functions for your domain

The decision is which shape matches the question. Plain assert for nine tests out of ten; pytest.raises and pytest.approx for the cases it can't reach; custom helpers for shape checks you'd otherwise repeat.

A worked example — full API response check

Pulling the patterns together into one realistic test:

import pytest
import requests
 
def assert_status_ok(response):
    __tracebackhide__ = True
    assert response.status_code == 200, \
        f"expected 200, got {response.status_code}: {response.text[:200]}"
    assert response.elapsed.total_seconds() < 2.0, \
        f"slow response: {response.elapsed.total_seconds():.3f}s"
 
def assert_valid_user(user):
    __tracebackhide__ = True
    assert isinstance(user, dict), f"expected dict, got {type(user).__name__}"
    for field in ("id", "name", "email"):
        assert field in user, f"user missing {field!r}"
    assert "@" in user["email"], f"bad email {user['email']!r}"
 
 
def test_user_endpoint():
    response = requests.get("https://jsonplaceholder.typicode.com/users/1", timeout=5)
    assert_status_ok(response)
    user = response.json()
    assert_valid_user(user)
    assert user["id"] == 1
 
 
def test_get_missing_user_returns_404():
    response = requests.get("https://jsonplaceholder.typicode.com/users/9999", timeout=5)
    assert response.status_code == 404
 
 
def test_set_timeout_rejects_negatives():
    from helpers import set_timeout
    with pytest.raises(ValueError, match="positive"):
        set_timeout(-1)
 
 
def test_pass_rate_within_tolerance():
    from helpers import compute_pass_rate
    assert compute_pass_rate(28, 32) == pytest.approx(0.875, abs=1e-6)

Three patterns visible at once: helpers with descriptive messages, pytest.raises with match, and pytest.approx for the float comparison. Each test reads as the question it's asking.

⚠️ Common mistakes

  • pytest.raises without an exception type. with pytest.raises(): is a TypeError — you must specify the exception class. with pytest.raises(Exception): works but is too broad — it'll catch the bug you didn't expect, including AttributeError from your test code itself.
  • Comparing floats with ==. 0.1 + 0.2 == 0.3 is False because of IEEE-754. Use pytest.approx (or compare integers when you can — milliseconds instead of seconds).
  • Hand-written assertion library. It's tempting to build assertEquals(actual, expected) like JUnit. pytest's plain assert is already richer than that — every wrapper layer just hides the rewriting. Use plain assert; lift to a custom helper only when the shape of the check repeats across many tests.

🎯 Practice task

Build an assertion-rich suite. 25-30 minutes.

  1. Create tests/test_helpers.py against the helpers.py from lesson 1 (is_valid_email, format_status, unique_priorities).
  2. Use pytest.raises to assert that format_status("not an int") raises TypeError (you may need to add a small type check inside the function first).
  3. Add a parametrized test for is_valid_email covering at least eight edge cases, mixing valid (alice@test.com) and invalid ("", "no-at", "a@b", "a.b@.com").
  4. Write a assert_valid_user(user) helper in a helpers/asserts.py module. Inside, use __tracebackhide__ = True and asserts with descriptive messages. Use it in a test against a sample dict.
  5. Use pytest.approx to assert a calculated pass rate is within tolerance of the expected value — compute_pass_rate(28, 32) == pytest.approx(0.875, abs=1e-6).
  6. Use with pytest.raises(ValueError, match="positive") as exc_info: to capture an exception, then assert on exc_info.value.args[0] to confirm the message.
  7. Use set equality to assert unique_priorities([{"priority": "P0"}, {"priority": "P1"}, {"priority": "P0"}]) == {"P0", "P1"}.
  8. Run the suite with pytest -v. Inspect the failure output for one deliberate failure to see pytest's diff and your custom message.
  9. Stretch: install pytest-check and write a test that checks five fields of a sample dict with check.equal(...). Make two of the fields wrong and confirm both failures are reported in the same test run.

You can now express any assertion a real test needs. The final lesson of this chapter covers the output side — generating HTML, Allure, and JUnit reports so your CI and teammates can see what ran and what failed.

// tip to track lessons you complete and pick up where you left off across devices.