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 == 404exc_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 toleranceapprox 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-checkimport 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.raiseswithout an exception type.with pytest.raises():is aTypeError— you must specify the exception class.with pytest.raises(Exception):works but is too broad — it'll catch the bug you didn't expect, includingAttributeErrorfrom your test code itself.- Comparing floats with
==.0.1 + 0.2 == 0.3is False because of IEEE-754. Usepytest.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 plainassertis already richer than that — every wrapper layer just hides the rewriting. Use plainassert; 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.
- Create
tests/test_helpers.pyagainst thehelpers.pyfrom lesson 1 (is_valid_email,format_status,unique_priorities). - Use
pytest.raisesto assert thatformat_status("not an int")raisesTypeError(you may need to add a small type check inside the function first). - Add a parametrized test for
is_valid_emailcovering at least eight edge cases, mixing valid (alice@test.com) and invalid ("","no-at","a@b","a.b@.com"). - Write a
assert_valid_user(user)helper in ahelpers/asserts.pymodule. Inside, use__tracebackhide__ = Trueand asserts with descriptive messages. Use it in a test against a sample dict. - Use
pytest.approxto assert a calculated pass rate is within tolerance of the expected value —compute_pass_rate(28, 32) == pytest.approx(0.875, abs=1e-6). - Use
with pytest.raises(ValueError, match="positive") as exc_info:to capture an exception, then assert onexc_info.value.args[0]to confirm the message. - Use set equality to assert
unique_priorities([{"priority": "P0"}, {"priority": "P1"}, {"priority": "P0"}]) == {"P0", "P1"}. - Run the suite with
pytest -v. Inspect the failure output for one deliberate failure to see pytest's diff and your custom message. - Stretch: install
pytest-checkand write a test that checks five fields of a sample dict withcheck.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.