Testing#
The de-facto stack: pytest as the runner, hypothesis for
property-based tests, coverage.py for code-coverage measurement.
unittest is in the stdlib but most projects layer pytest on
top of it (pytest can run unittest test classes unchanged).
Layout#
A pytest-native layout.
project/
pyproject.toml
src/mypkg/
__init__.py
widget.py
tests/
conftest.py # shared fixtures
test_widget.py
test_api.py
Run:
$ pytest -v
$ pytest -v tests/test_widget.py::test_resize
A test#
# tests/test_widget.py
from mypkg.widget import Widget
def test_resize():
w = Widget(10, 20)
w.resize(100)
assert w.width == 100
assert w.height == 20
def test_invalid_resize():
w = Widget(10, 20)
with pytest.raises(ValueError, match="must be positive"):
w.resize(-1)
Pytest runs every function that starts with test_ in any file
that starts with test_. Failures show a colorized diff of the
asserted expression.
Fixtures#
A fixture is a setup helper. Pytest injects fixtures into tests by
name; they handle their own teardown via yield.
import pytest, tempfile, pathlib
@pytest.fixture
def tmp_db():
with tempfile.NamedTemporaryFile(suffix=".db") as f:
db = open_db(f.name)
yield db
db.close()
def test_insert(tmp_db):
tmp_db.execute("INSERT INTO ...")
Built-in fixtures worth knowing.
Fixture |
What it gives you |
|---|---|
|
|
|
Same, session-scoped |
|
Capture stdout / stderr from the code under test |
|
Patch attributes / env / sys.path; reverts after the test |
|
Capture log records via |
|
Access CLI flags / config |
Scopes (function, class, module, session) control how
often the fixture’s setup runs.
Parametrize#
Run the same test against many inputs.
@pytest.mark.parametrize("a,b,want", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, want):
assert add(a, b) == want
Mocking#
def test_calls_api(monkeypatch):
calls = []
monkeypatch.setattr(http, "get", lambda url, **kw: calls.append(url))
run()
assert calls == ["https://api/x"]
# or with unittest.mock for the pure-stdlib version
from unittest.mock import patch, MagicMock
with patch("mypkg.client.fetch", return_value={"ok": True}):
result = run()
Property-based tests#
Hypothesis generates inputs that try to break your code, then shrinks failing cases to the minimal counterexample.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_idempotent(xs):
assert sorted(sorted(xs)) == sorted(xs)
Coverage#
$ pip install pytest-cov
$ pytest --cov=src --cov-report=term-missing
$ pytest --cov=src --cov-report=html # opens htmlcov/index.html
Coverage tells you which lines weren’t exercised; it doesn’t tell you whether tests are good. Aim for meaningful tests over a target percentage.
Test selection#
$ pytest -k "widget and not slow" # by name expression
$ pytest -m smoke # by mark
$ pytest --lf # last-failed
$ pytest --ff # failed-first
$ pytest -x # stop on first failure
$ pytest --pdb # drop into pdb on failure
$ pytest -n auto # parallel (with pytest-xdist)
What to test#
Public API of each module, the contract callers depend on.
Boundary conditions, empty inputs, off-by-one, max sizes.
Error paths,
pytest.raises(SomeError).Bug regressions, write the failing test, then fix.
Critical workflows, end-to-end via a small in-process fixture.
What not to test: trivial getters, framework code, third-party libraries themselves.
See also: https://docs.pytest.org/, https://hypothesis.readthedocs.io/, https://coverage.readthedocs.io/.