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.

17 min 9 sections

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:

bash
uv add --dev pytest
python
# 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_*:

python
# 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) == 999
bash
uv run pytest
text
============================= 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:

text
    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:

python
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_percent tells 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:

python
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) == expected

Each 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:

python
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:

python
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() == 360

Each 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:

python
@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:

python
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:

python
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:

python
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:

bash
uv add --dev pytest-cov
uv run pytest --cov=shop --cov-report=term-missing
text
Name                  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.

python
# 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 base

Use parametrize for the price table, pytest.raises for the errors, and cover the boundaries.

Show solution
python
# 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:

text
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 == 743

The 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.

python
    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 base

Key takeaways

  • pytest tests are plain functions with plain asserts in tests/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.parametrize turns a table of cases into separate tests; pytest.raises(..., match=...) checks errors.
  • Fixtures provide fresh setup per test (with yield for teardown); tmp_path, monkeypatch, capsys and caplog are 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. 1.Two tests both use a cart_with_items fixture, and one of them removes an item. Does that affect the other test?

  2. 2.Which test is most likely to catch an off-by-one error in 'free shipping for orders of 50 euros or more'?

  3. 3.shop/users.py does from shop.mail import send_email. Which path should patch() target to replace it in register_user?

  4. 4.Your suite reports 100% coverage. What does that guarantee?

Finished reading?

Mark it complete to fill in your roadmap.