Your First Pytest Test
Write a test, run it, and read pytest's output — including the assertion introspection that makes a failure tell you what actually went wrong.
pytest runs a function whose name starts with test_ and reports whether its assertions
held. That is the whole model — everything else is convenience built on top.
Installing
pip install pytest
Successfully installed iniconfig-2.0.0 packaging-24.2 pluggy-1.5.0 pytest-8.3.4
Something to test
# shipping.py
def shipping_cost(weight_kg: float, express: bool = False) -> float:
"""Cost in GBP. Free over 10kg on standard delivery."""
if weight_kg <= 0:
raise ValueError(f"weight must be positive, got {weight_kg}")
base = 3.99 if weight_kg < 2 else 6.99
if weight_kg >= 10 and not express:
base = 0.0
return round(base * (2.5 if express else 1.0), 2)
The first test
# test_shipping.py
from shipping import shipping_cost
def test_light_parcel_costs_base_rate():
assert shipping_cost(1.5) == 3.99
pytest
========================= test session starts =========================
platform linux -- Python 3.11.9, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/you/shipping
collected 1 item
test_shipping.py . [100%]
========================== 1 passed in 0.01s ==========================
The . after the filename is the test passing. One character per test — pytest’s progress
output is dense on purpose, because a real suite has thousands.
A failure that tells you something
This is the reason to use pytest. Add a test with the wrong expectation:
def test_heavy_parcel_is_free():
assert shipping_cost(12) == 6.99
========================= test session starts =========================
collected 2 items
test_shipping.py .F [100%]
============================== FAILURES ===============================
_______________________ test_heavy_parcel_is_free ______________________
def test_heavy_parcel_is_free():
> assert shipping_cost(12) == 6.99
E assert 0.0 == 6.99
E + where 0.0 = shipping_cost(12)
test_shipping.py:9: AssertionError
======================= short test summary info =======================
FAILED test_shipping.py::test_heavy_parcel_is_free - assert 0.0 == 6.99
==================== 1 failed, 1 passed in 0.02s ======================
Read what it gave you from a bare assert: the failing line, both sides of the comparison
(0.0 == 6.99), and the call that produced the left side. Python’s own assert would have
printed AssertionError and nothing else.
That is assertion introspection — pytest rewrites your test module’s bytecode at import
time so it can reconstruct the expression. It is why you never need assertEqual.
Comparing collections
The introspection gets more valuable as values get bigger:
def test_price_table():
expected = {"small": 3.99, "medium": 6.99, "large": 6.99, "free": 0.0}
actual = {
"small": shipping_cost(1),
"medium": shipping_cost(5),
"large": shipping_cost(9),
"free": shipping_cost(15),
}
assert actual == expected
Change one expected value and run it:
_____________________________ test_price_table _____________________________
def test_price_table():
expected = {"small": 3.99, "medium": 6.99, "large": 7.99, "free": 0.0}
...
> assert actual == expected
E AssertionError: assert {'free': 0.0, ...} == {'free': 0.0, ...}
E
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'large': 6.99} != {'large': 7.99}
E
E Full diff:
E {
E 'free': 0.0,
E - 'large': 7.99,
E ? ^
E + 'large': 6.99,
E ? ^
E 'medium': 6.99,
E 'small': 3.99,
E }
It hid the three matching keys, named the differing one, and pointed at the character that differs. On a dict with fifty keys this is the difference between a two-second fix and ten minutes of squinting.
Testing that something raises
import pytest
def test_negative_weight_rejected():
with pytest.raises(ValueError) as exc_info:
shipping_cost(-1)
assert "must be positive" in str(exc_info.value)
test_shipping.py::test_negative_weight_rejected PASSED [100%]
The test passes only if the block raises ValueError. If it raises nothing:
E Failed: DID NOT RAISE <class 'ValueError'>
And if it raises the wrong type, the original exception propagates so you see the real traceback rather than a vague assertion failure.
Match the message directly with match=, which takes a regex:
def test_error_names_the_bad_value():
with pytest.raises(ValueError, match=r"got -1"):
shipping_cost(-1)
Floats need approx
def test_express_surcharge():
assert shipping_cost(1, express=True) == 3.99 * 2.5
> assert shipping_cost(1, express=True) == 3.99 * 2.5
E assert 9.98 == 9.975000000000001
E + where 9.98 = shipping_cost(1, express=True)
The function rounds; the test does not. Comparing floats for exact equality fails on almost any arithmetic:
def test_express_surcharge():
assert shipping_cost(1, express=True) == pytest.approx(9.975, abs=0.01)
test_shipping.py::test_express_surcharge PASSED [100%]
pytest.approx also works on lists, dicts and numpy arrays, which saves writing a
tolerance loop.
Useful command-line flags
pytest -v
test_shipping.py::test_light_parcel_costs_base_rate PASSED [ 25%]
test_shipping.py::test_heavy_parcel_is_free PASSED [ 50%]
test_shipping.py::test_negative_weight_rejected PASSED [ 75%]
test_shipping.py::test_express_surcharge PASSED [100%]
pytest -k express # only tests whose name matches
pytest test_shipping.py::test_price_table # one specific test
pytest -x # stop at the first failure
pytest --lf # re-run only last-failed
pytest -q # quiet
pytest -s # do not capture stdout, so print() shows
--lf is the one that changes your day. After a run with twelve failures, fix one thing and
pytest --lf re-runs only those twelve instead of the whole suite.
Captured output
pytest hides print from passing tests and shows it for failing ones:
def test_with_logging():
print("computing cost for 5kg")
cost = shipping_cost(5)
print(f"got {cost}")
assert cost == 99.99 # deliberately wrong
_____________________________ test_with_logging _____________________________
> assert cost == 99.99
E assert 6.99 == 99.99
---------------------------- Captured stdout call ---------------------------
computing cost for 5kg
got 6.99
The Captured stdout call section appears only on failure. That is why a print you added
for debugging seems to vanish — the test passed. Use -s to see it regardless.
Practice
1. Write a test asserting shipping_cost(10) is free, and one asserting the express rate at 10kg is not.
def test_ten_kg_standard_is_free():
assert shipping_cost(10) == 0.0
def test_ten_kg_express_still_charged():
assert shipping_cost(10, express=True) == pytest.approx(17.48, abs=0.01)
test_shipping.py::test_ten_kg_standard_is_free PASSED [ 50%]
test_shipping.py::test_ten_kg_express_still_charged PASSED [100%]
10kg is the boundary — >= means it qualifies. Boundaries are where bugs live, so test the
value itself, not just either side.
2. Rename a test function to check_something and run pytest.
collected 4 items
One fewer than before — it silently disappeared. pytest only collects functions starting with
test_. When a test “isn’t running”, check the name first; there is no warning.
3. Assert two long lists differ and read the output.
E AssertionError: assert [1, 2, 3, 4, 5...] == [1, 2, 3, 4, 6...]
E At index 4 diff: 5 != 6
E Use -v to get more diff
It names the first differing index rather than dumping both lists. Add -vv for the complete
diff when the lists are large.
4. Use pytest.raises for an exception that is never raised.
E Failed: DID NOT RAISE <class 'ValueError'>
A clear, specific failure. This matters because the opposite mistake — wrapping code in
try/except and asserting nothing — passes silently whether or not the error occurs.
Next: fixtures, and how to stop repeating setup in every test.