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
ScopeInstantiatedUse for
function (default)Once per test functionFresh state that must not leak
classOnce per test classShared setup inside one group
moduleOnce per test fileExpensive parsing or a temp directory
sessionOnce per test runDatabase or container started for all tests
⚠️
Session-scoped fixtures are built once, so any mutation inside a test leaks into every test that follows and produces order-dependent failures. Keep mutable state in function-scoped fixtures and share only genuinely read-only resources.

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 --pdb to drop into a debugger at the failing assertion.

FAQ

How do I test code that reads the current time or a random value?
Inject the dependency: accept a 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?
Enough to catch a regression in every behaviour you care about, starting with the paths that handle money, permissions and persisted data. Aim for tests that fail loudly and diagnose precisely, not for a coverage percentage.

Packaging, project layout and pyproject.toml Type hints and static checking

Last refreshed 2026-09-18.