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.