Frontend Labs
⋯

20. Testing with pytest and Hypothesis

20.1 Plain assert, discovered automatically

# src/textutil.py
def slugify(title: str) -> str:
    """'Hello, World!' -> 'hello-world'"""
    keep = "".join(c.lower() if c.isalnum() else " " for c in title)
    return "-".join(keep.split())
# tests/test_textutil.py
import pytest
from textutil import slugify

def test_basic():
    assert slugify("Hello, World!") == "hello-world"

@pytest.mark.parametrize(
    ("title", "expected"),
    [
        ("  spaced   out ", "spaced-out"),
        ("Café au lait", "café-au-lait"),
        ("", ""),
        ("---", ""),
    ],
)
def test_cases(title: str, expected: str):
    assert slugify(title) == expected

def test_rejects_none():
    with pytest.raises(TypeError):
        slugify(None)  # type: ignore[arg-type]
uv run pytest -q                 # all tests
uv run pytest -k cases -x -vv    # filter by name, stop at first failure, verbose

pytest rewrites assert so a failure shows both values and a diff. No assertEqual boilerplate.

20.2 Fixtures: setup that tests ask for by name

# tests/test_store.py
import json
import pytest

@pytest.fixture
def store(tmp_path):                         # tmp_path is a built-in fixture: a fresh dir
    path = tmp_path / "store.json"
    path.write_text(json.dumps({"users": ["ada"]}))
    return path

def test_reads_users(store):
    assert json.loads(store.read_text())["users"] == ["ada"]

def test_env(monkeypatch):                   # monkeypatch: undo-able patches
    monkeypatch.setenv("APP_ENV", "test")
    import os
    assert os.environ["APP_ENV"] == "test"

Fixtures can depend on other fixtures, be scoped per session (scope= "session" for an expensive database or model load), and clean up after a yield. Put shared fixtures in tests/conftest.py.

20.3 Mocking the edge, not the middle

from unittest.mock import Mock

def fetch_status(client, url: str) -> str:
    r = client.get(url, timeout=5)
    return "up" if r.status_code < 500 else "down"

def test_fetch_status_down():
    client = Mock()
    client.get.return_value = Mock(status_code=503)
    assert fetch_status(client, "https://x") == "down"
    client.get.assert_called_once_with("https://x", timeout=5)

Mock things you do not own and that are slow or nondeterministic (network, clock, randomness) at the boundary of your code. Passing the client in as a parameter, as here, makes that trivial. Tests full of patch("a.b.c.d") are a sign the code under test reaches out to globals.

20.4 Property-based testing with Hypothesis

Instead of choosing examples, state a property and let Hypothesis generate hundreds of inputs, including nasty ones you would not think of:

from hypothesis import given, strategies as st
from textutil import slugify

@given(st.text())
def test_slug_is_idempotent(s: str):
    assert slugify(slugify(s)) == slugify(s)

@given(st.text())
def test_slug_has_no_spaces_or_edges(s: str):
    out = slugify(s)
    assert " " not in out and not out.startswith("-") and not out.endswith("-")

When a property fails, Hypothesis shrinks the input to the smallest failing example. This is the Python cousin of fuzzing in the Rust guide, and it is the best tool for parsers and serializers: assert that decode(encode(x)) == x for all x.

20.5 What to test, and coverage

Test behaviour through public functions, one behaviour per test, with names that read as specifications (test_expired_token_is_rejected). uv add --dev pytest-cov and pytest --cov=src --cov-report=term-missing show untested lines. Coverage tells you what you did not test; it does not tell you that what you tested is right.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36