Step 08 of 13
Testing with pytest
Write fast, readable tests with pytest: plain asserts, parametrize, fixtures, tmp_path and monkeypatch, testing errors, fakes versus mocks, and coverage.
Every program gets tested. The only question is whether a person does it by hand after every change, or code does it in two seconds. Automated tests let you change code without fear: refactor a function, upgrade a library, and know within moments whether anything broke. pytest is the standard Python test runner, and it keeps tests short: plain functions with plain assert statements. This lesson covers writing and running tests, parametrisation, fixtures, testing errors and side effects, and when to use fakes or mocks.
Your first test
Add pytest to your project and create a module to test:
uv add --dev pytest# src/shop/pricing.py
def apply_discount(price_cents: int, percent: int) -> int:
"""Return the price after a percentage discount, rounded to the nearest cent."""
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100, got {percent}")
return round(price_cents * (100 - percent) / 100)Tests live in a tests/ folder, in files named test_*.py, as functions named test_*:
# tests/test_pricing.py
from shop.pricing import apply_discount
def test_ten_percent_off():
assert apply_discount(2000, 10) == 1800
def test_zero_percent_changes_nothing():
assert apply_discount(999, 0) == 999uv run pytest============================= test session starts ==============================
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================Each . is a passing test. When an assertion fails, pytest shows the values involved, without you writing any messages:
def test_ten_percent_off():
> assert apply_discount(2000, 10) == 1700
E assert 1800 == 1700
E + where 1800 = apply_discount(2000, 10)Useful options: -v lists each test by name, -x stops at the first failure, -k discount runs only tests whose names contain "discount", and --lf reruns only the tests that failed last time.
What makes a good test
A good test reads like a specification, in three parts, often called arrange, act, assert:
def test_cart_total_includes_quantities():
# arrange
cart = Cart()
cart.add("PEN", price_cents=120, quantity=3)
cart.add("BAG", price_cents=3500)
# act
total = cart.total_cents()
# assert
assert total == 3860- One behaviour per test, named for that behaviour:
test_discount_rejects_negative_percenttells you what broke without reading the body. - Test behaviour, not implementation. Assert on what the function returns or does, not on how it does it, so refactoring doesn't break tests.
- Fast and independent. Tests shouldn't depend on each other's order or on the network. A suite that runs in seconds gets run constantly.
- Cover the edges: empty inputs, zero, the boundaries (0 and 100 percent), invalid values. Bugs live at the edges.
Parametrize: many cases, one test
Writing a function per case gets repetitive. @pytest.mark.parametrize runs one test with a table of inputs:
import pytest
from shop.pricing import apply_discount
@pytest.mark.parametrize(
("price", "percent", "expected"),
[
(2000, 10, 1800),
(999, 0, 999),
(999, 100, 0),
(1999, 15, 1699), # 1699.15 rounds down
(1, 50, 0), # 0.5 rounds to even: 0
],
)
def test_apply_discount(price, percent, expected):
assert apply_discount(price, percent) == expectedEach row is reported as its own test (test_apply_discount[1999-15-1699]), so a failure tells you exactly which case broke. The last row documents a real surprise: Python's round uses banker's rounding, so round(0.5) is 0. A test is a good place to pin down behaviour like that, deliberately.
Testing errors
Use pytest.raises as a context manager to assert that code raises, and match to check the message with a regular expression:
def test_discount_rejects_percent_over_100():
with pytest.raises(ValueError, match="between 0 and 100"):
apply_discount(1000, 150)The test fails if no exception is raised, or if a different type is raised.
Fixtures: shared setup
A fixture is a function that provides something a test needs. Tests ask for it by naming it as a parameter, and pytest calls it for them:
import pytest
from shop.cart import Cart
@pytest.fixture
def cart_with_items() -> Cart:
cart = Cart()
cart.add("PEN", price_cents=120, quantity=3)
cart.add("BAG", price_cents=3500)
return cart
def test_total(cart_with_items):
assert cart_with_items.total_cents() == 3860
def test_remove_item(cart_with_items):
cart_with_items.remove("BAG")
assert cart_with_items.total_cents() == 360Each test gets a fresh fixture value, so test_remove_item changing the cart can't affect test_total. Put fixtures shared by several test files in tests/conftest.py; pytest finds them automatically.
A fixture that needs cleanup uses yield: code before yield is setup, code after is teardown, and teardown runs even if the test fails:
@pytest.fixture
def db():
conn = connect_test_database()
yield conn
conn.rollback()
conn.close()scope="session" makes an expensive fixture (starting a database container, say) run once for the whole test run instead of once per test.
Built-in fixtures
pytest provides several, and two are used constantly:
def test_save_report(tmp_path):
"""tmp_path is a fresh temporary directory, unique to this test."""
out = tmp_path / "report.csv"
save_report(out, rows=[{"sku": "PEN", "qty": 3}])
assert out.read_text(encoding="utf-8").splitlines() == ["sku,qty", "PEN,3"]
def test_reads_api_key_from_env(monkeypatch):
"""monkeypatch changes environment variables, attributes or dict items, and undoes it after the test."""
monkeypatch.setenv("WEATHER_API_KEY", "test-key")
assert load_settings().api_key == "test-key"capsys captures printed output (capsys.readouterr().out), and caplog captures log records.
Fakes, mocks and designing for tests
Code that sends email, calls an API or reads the clock is hard to test directly: it's slow, flaky, has side effects, and gives different answers each time. There are two ways to replace the real thing.
Fakes: a simple working implementation you pass in. If the code takes its collaborators as parameters (as in the classes lesson), testing is easy:
class FakeMailer:
def __init__(self) -> None:
self.sent: list[tuple[str, str]] = []
def send(self, to: str, body: str) -> None:
self.sent.append((to, body))
def test_welcome_email_is_sent():
mailer = FakeMailer()
register_user("[email protected]", mailer=mailer)
assert mailer.sent == [("[email protected]", "Welcome to the shop!")]Mocks: objects that record how they were called, often patched into place with unittest.mock:
from unittest.mock import patch
def test_register_sends_email():
with patch("shop.users.send_email") as send_email:
register_user("[email protected]")
send_email.assert_called_once_with("[email protected]", "Welcome to the shop!")Mocks work on code that wasn't designed for testing, which is why they're common. But they couple tests to implementation details: rename send_email, or patch the wrong import path (you patch where a name is used, not where it's defined), and tests break or silently test nothing. Prefer fakes and passing dependencies in; reach for patch at the boundaries you can't change, such as time.time or a third-party client.
Coverage
Coverage measures which lines your tests execute:
uv add --dev pytest-cov
uv run pytest --cov=shop --cov-report=term-missingName Stmts Miss Cover Missing
---------------------------------------------------
src/shop/cart.py 24 2 92% 31-32
src/shop/pricing.py 5 0 100%The Missing column shows untested lines, often error paths, which is useful. Treat the percentage as a flashlight, not a target: 100% coverage with weak assertions proves little, and chasing the last few percent can produce tests that only exist to touch lines.
Practice
Here's a function that calculates shipping costs. Write tests for it until you're confident, find the bug, and fix it.
# src/shop/shipping.py
def shipping_cost_cents(weight_grams: int, country: str, express: bool = False) -> int:
"""Shipping cost in cents.
- Domestic (country "NL"): 495 up to 2,000 g, 695 up to 10,000 g.
- Elsewhere in the EU: double the domestic price.
- Outside the EU: not supported, raise ValueError.
- Over 10,000 g: not supported, raise ValueError.
- Express adds 50%, rounded to the nearest cent.
"""
EU = {"NL", "BE", "DE", "FR", "ES", "IT"}
if country not in EU:
raise ValueError(f"we don't ship to {country}")
if weight_grams > 10_000:
raise ValueError("parcel too heavy")
base = 495 if weight_grams < 2_000 else 695
if country != "NL":
base *= 2
if express:
base = round(base * 1.5)
return baseUse parametrize for the price table, pytest.raises for the errors, and cover the boundaries.
Show solution
# tests/test_shipping.py
import pytest
from shop.shipping import shipping_cost_cents
@pytest.mark.parametrize(
("weight", "country", "express", "expected"),
[
(1, "NL", False, 495),
(2_000, "NL", False, 495), # boundary: "up to 2,000 g" includes 2,000
(2_001, "NL", False, 695),
(10_000, "NL", False, 695), # boundary: the heaviest allowed parcel
(500, "DE", False, 990), # EU: double
(5_000, "FR", False, 1390),
(500, "NL", True, 743), # 495 × 1.5 = 742.5, rounded up
(5_000, "BE", True, 2085),
],
)
def test_shipping_cost(weight, country, express, expected):
assert shipping_cost_cents(weight, country, express) == expected
def test_rejects_countries_outside_the_eu():
with pytest.raises(ValueError, match="don't ship to US"):
shipping_cost_cents(500, "US")
def test_rejects_parcels_over_10kg():
with pytest.raises(ValueError, match="too heavy"):
shipping_cost_cents(10_001, "NL")Running this finds two problems:
FAILED tests/test_shipping.py::test_shipping_cost[2000-NL-False-495] - assert 695 == 495
FAILED tests/test_shipping.py::test_shipping_cost[500-NL-True-743] - assert 742 == 743The real bug is the boundary: the docstring says 495 "up to 2,000 g", which includes 2,000, but the code uses < 2_000. Fix it with <=. Testing exactly at the boundary is what caught it; tests at 1,000 g and 5,000 g would all have passed.
The second failure is a mistake in the test, and an instructive one. 495 * 1.5 = 742.5, and Python's round rounds halves to the even neighbour, giving 742. Whether that's acceptable is a business question. If the shop wants halves rounded up, change the implementation to integer arithmetic, which also avoids floating-point surprises: base = (base * 3 + 1) // 2. Then 743 is correct and the test passes. Either way, a test now documents the decision.
base = 495 if weight_grams <= 2_000 else 695
if country != "NL":
base *= 2
if express:
base = (base * 3 + 1) // 2 # +50%, halves rounded up
return baseKey takeaways
- pytest tests are plain functions with plain
asserts intests/test_*.py; failures show the actual values. - Write one behaviour per test with a descriptive name, test behaviour rather than implementation, and cover the boundaries.
@pytest.mark.parametrizeturns a table of cases into separate tests;pytest.raises(..., match=...)checks errors.- Fixtures provide fresh setup per test (with
yieldfor teardown);tmp_path,monkeypatch,capsysandcaplogare built in. - Prefer fakes passed in as dependencies over patching with mocks, and use coverage to find untested paths rather than as a score.
Check yourself
Quiz
0/4 correct · 4 left
1.Two tests both use a cart_with_items fixture, and one of them removes an item. Does that affect the other test?
2.Which test is most likely to catch an off-by-one error in 'free shipping for orders of 50 euros or more'?
3.shop/users.py does from shop.mail import send_email. Which path should patch() target to replace it in register_user?
4.Your suite reports 100% coverage. What does that guarantee?
Finished reading?
Mark it complete to fill in your roadmap.