Testing with pytest
Test discovery, plain asserts, fixtures, parametrisation, pytest.raises, monkeypatching and coverage.
Files, discovery and asserts
pytest discovers files named test_*.py or *_test.py, and functions named test_* inside them. There is no class to inherit and no special assert method: plain assert statements are rewritten to report the actual values.
pip install pytest
# layout
# src/shop/cart.py
# tests/test_cart.py
# src/shop/cart.py
class Cart:
def __init__(self):
self.items = []
def add(self, price, qty=1):
if qty < 1:
raise ValueError("qty must be at least 1")
self.items.extend([price] * qty)
def total(self):
return sum(self.items)
# tests/test_cart.py
from shop.cart import Cart
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_add_multiplies_by_quantity():
cart = Cart()
cart.add(2.5, 3)
assert cart.total() == 7.5
def test_negative_quantity_is_rejected():
import pytest
with pytest.raises(ValueError, match="at least 1"):
Cart().add(2.5, 0)pytest # run everything
pytest tests/test_cart.py # one file
pytest -k "quantity and not slow" # filter by name
pytest -x # stop at the first failure
pytest -vv -s # verbose, do not capture output
pytest --lf # rerun only what failed last time- One behaviour per test, with a name that states the expectation — a failing test should tell you what broke without opening the file.
- Arrange, Act, Assert: set up, do the one thing, check the result.
- Never assert on a value the code under test also produced by the same call; compare against a literal or an independently built value.
Fixtures and temporary state
A fixture is a function decorated with @pytest.fixture. Tests request it by naming it as a parameter, and pytest passes in the return value. Fixtures build resources, and their teardown code runs after the test.
import pytest
from shop.db import connect
from shop.cart import Cart
@pytest.fixture
def cart():
return Cart()
@pytest.fixture(scope="session") # built once for the whole run
def db():
conn = connect("postgresql://localhost/test")
yield conn # everything after yield is teardown
conn.close()
@pytest.fixture
def client(tmp_path): # tmp_path is built in: a unique dir
path = tmp_path / "config.json"
path.write_text('{"retries": 3}', encoding="utf-8")
return load_config(path)
def test_total_uses_two_rows(cart):
cart.add(1.0, 2)
assert cart.total() == 2.0
def test_rollback_leaves_no_rows(db, cart):
db.execute("insert into carts values (1)")
db.rollback()
assert db.count("carts") == 0
# swap an external dependency for the duration of one test
def test_send_uses_the_api(monkeypatch):
calls = []
monkeypatch.setattr("shop.mail.send", lambda to, body: calls.append(to))
notify("[email protected]")
assert calls == ["[email protected]"]
monkeypatch.setenv("SHOP_MODE", "test") # also patches env vars and cwd| Scope | Instantiated | Use for |
|---|---|---|
function (default) | Once per test function | Fresh state that must not leak |
class | Once per test class | Shared setup inside one group |
module | Once per test file | Expensive parsing or a temp directory |
session | Once per test run | Database or container started for all tests |
Parametrisation and coverage
@pytest.mark.parametrize runs one test body against many inputs and reports each case separately, which is far better than a loop whose first failure hides the rest.
import pytest
@pytest.mark.parametrize(
"prices, expected",
[
([], 0),
([10.0], 10.0),
([10.0, 2.5], 12.5),
],
ids=["empty", "single", "sum"],
)
def test_total(prices, expected):
cart = Cart()
for p in prices:
cart.add(p)
assert cart.total() == expected
@pytest.mark.parametrize("qty", [0, -1, -100])
def test_invalid_quantity(qty):
with pytest.raises(ValueError):
Cart().add(1.0, qty)
@pytest.mark.skip(reason="pending rewrite")
def test_legacy_report(): ...
@pytest.mark.xfail(reason="known rounding issue", strict=True)
def test_rounding(): assert Cart().total() == 0.3
# a missing dependency should skip rather than fail
def test_pdf_export():
pytest.importorskip("reportlab")pytest --cov=shop --cov-report=term-missing tests/prints the lines nothing exercises.- Coverage finds untested code, not wrong code. A branch that is never run at all is the useful signal.
- Prefer a fixture over
setup_method; keep module-level constants and helpers out of the test names pytest collects. - Reproduce a failure with
--pdbto drop into a debugger at the failing assertion.
FAQ
How do I test code that reads the current time or a random value?
now function or a seed as a parameter and pass a fixed value in tests. If the call is buried in a third-party module, patch it with monkeypatch.setattr.How many tests do I need?
Related
Packaging, project layout and pyproject.toml Type hints and static checking
Last refreshed 2026-09-18.