Tests That Read Like Docs: A Practical Deep Dive into pytest
Go beyond assert True. Learn how pytest's plain assertions, fixtures, parametrization, built-in helpers like tmp_path and monkeypatch, and markers combine into a fast, readable test suite — with runnable examples and the pitfalls to avoid.
Most Python projects reach a point where "I ran it and it looked fine" stops being good enough. You refactor a function, something three modules away breaks, and you don't find out until a user does. A good test suite is the antidote — and in the Python world, pytest is the tool that makes writing one feel light instead of like a chore.
pytest's appeal is that it stays out of your way. There are no TestCase classes to inherit from, no self.assertEqual ceremony — just plain functions and plain assert statements. But underneath that simple surface is a genuinely powerful system: fixtures for setup and teardown, parametrization for running one test against many inputs, and a rich set of built-in helpers. This post walks through the parts you'll use every day, with runnable examples.
Getting started: plain functions, plain asserts
Install pytest into your virtual environment and you're ready to go:
pip install pytestpytest discovers tests by convention: files named test_*.py or *_test.py, and functions inside them named test_*. Say we have this small module, shop.py:
class InsufficientStock(Exception):
pass
def apply_discount(price, pct):
if not 0 <= pct <= 100:
raise ValueError("pct must be between 0 and 100")
return round(price * (1 - pct / 100), 2)
class Cart:
def __init__(self):
self.items = {}
def add(self, name, qty):
if qty <= 0:
raise ValueError("qty must be positive")
self.items[name] = self.items.get(name, 0) + qty
def total_qty(self):
return sum(self.items.values())A first test file, test_basics.py, needs nothing more than the built-in assert:
from shop import apply_discount, Cart
def test_apply_discount_basic():
assert apply_discount(100, 10) == 90.0
def test_cart_add_and_total():
cart = Cart()
cart.add("apple", 3)
cart.add("apple", 2)
assert cart.total_qty() == 5Run it with pytest -q. When an assertion fails, pytest rewrites it behind the scenes to show you exactly what the two sides evaluated to — you get assert 89.9 == 90.0 with the operands spelled out, not a bare "assertion error." That introspection is one of the biggest reasons plain assert is enough.
Testing that errors happen: pytest.raises
Testing the unhappy path is just as important as the happy one. Use the pytest.raises context manager, and use its match argument (a regex checked against the exception message) so you're sure you got the right error, not just any error:
import pytest
from shop import apply_discount
def test_apply_discount_rejects_bad_pct():
with pytest.raises(ValueError, match="between 0 and 100"):
apply_discount(100, 150)For floating-point results, never assert exact equality — rounding makes 0.1 + 0.2 == 0.3 false. Reach for pytest.approx instead:
def test_apply_discount_float():
assert apply_discount(19.99, 15) == pytest.approx(16.99, abs=0.01)Parametrization: one test, many cases
When you find yourself copying a test and changing one value, that's the signal to parametrize. The @pytest.mark.parametrize decorator runs the same test body once per row, and — crucially — reports each row as a separate test. If case three fails, the other three still run and you see precisely which input broke:
import pytest
from shop import apply_discount
@pytest.mark.parametrize("price, pct, expected", [
(100, 0, 100.0),
(100, 25, 75.0),
(100, 100, 0.0),
(50, 10, 45.0),
])
def test_apply_discount_table(price, pct, expected):
assert apply_discount(price, pct) == expectedThis is far better than looping inside a single test: a loop stops at the first failure and hides the rest, while parametrization gives you an honest pass/fail count and clear test IDs like test_apply_discount_table[100-25-75.0].
Fixtures: reusable, composable setup
A fixture is a function decorated with @pytest.fixture that produces something your tests need — a configured object, a database connection, a temp directory. Any test that wants it simply names it as a parameter, and pytest injects the return value. This is dependency injection, and it replaces the fragile setUp/tearDown pattern of older frameworks.
import pytest
from shop import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("widget", 1)
return c
def test_uses_shared_cart(cart):
cart.add("gadget", 2)
assert cart.total_qty() == 3Each test gets a fresh cart by default, so tests never leak state into one another.
Teardown with yield
When a fixture needs cleanup, write it as a generator: everything before yield is setup, the yielded value is what the test receives, and everything after yield runs as teardown once the test finishes — even if it failed.
@pytest.fixture
def temp_db(tmp_path):
db = tmp_path / "data.txt"
db.write_text("seed")
yield db
print("cleaning up", db) # runs after the test
def test_reads_seed_data(temp_db):
assert temp_db.read_text() == "seed"conftest.py: sharing fixtures across files
Put a fixture in a file called conftest.py and every test module in that directory (and below) can use it without importing anything. pytest discovers it automatically. It's the standard home for fixtures shared across your suite.
Fixture scope
By default a fixture runs once per test (scope="function"). For something expensive — a database engine, a spun-up server — you can widen the scope to "module", "class", or "session" so it's created once and reused:
import pytest
@pytest.fixture(scope="module")
def expensive_resource():
resource = {"conn": "open"} # imagine an expensive setup
yield resource
resource["conn"] = "closed" # teardown once per module
def test_a(expensive_resource):
assert expensive_resource["conn"] == "open"
def test_b(expensive_resource):
assert expensive_resource["conn"] == "open"Be deliberate here: a widened scope means shared mutable state. If a test mutates a session-scoped fixture, later tests see the change. Reserve broad scopes for genuinely read-only or self-resetting resources.
Built-in fixtures you'll use constantly
pytest ships with fixtures you never have to define. Two of the most useful are tmp_path and monkeypatch.
tmp_path hands you a unique, automatically-cleaned pathlib.Path directory — perfect for tests that read and write files without polluting your project or clashing with parallel runs.
monkeypatch lets you safely set environment variables, attributes, and dict entries, and automatically undoes every change when the test ends. That auto-reset is the whole point: no leaked global state.
def test_monkeypatch_env(monkeypatch):
monkeypatch.setenv("API_TOKEN", "test-123")
import os
assert os.environ["API_TOKEN"] == "test-123"
# after the test, API_TOKEN is restored to whatever it was beforeReplacing slow or external calls
Tests should be fast and deterministic, which means real network calls have no place in a unit test. Suppose weather.py makes an HTTP request:
import urllib.request
def fetch_temp(city):
with urllib.request.urlopen(f"https://example.com/{city}") as r:
return float(r.read())
def describe(city):
t = fetch_temp(city)
return "hot" if t >= 25 else "mild"You can swap out fetch_temp with monkeypatch.setattr so describe runs against a value you control:
import weather
def test_describe_mild(monkeypatch):
monkeypatch.setattr(weather, "fetch_temp", lambda city: 18.0)
assert weather.describe("paris") == "mild"
def test_describe_hot(monkeypatch):
monkeypatch.setattr(weather, "fetch_temp", lambda city: 30.0)
assert weather.describe("cairo") == "hot"A key rule: patch the name where it is used, not where it is defined. Here describe calls fetch_temp as a name in the weather module, so we patch weather.fetch_temp. Patching the original source location instead is the single most common mocking mistake.
Markers: skip, xfail, and your own
Markers attach metadata to tests. Two built-ins earn their keep immediately. @pytest.mark.skipif skips a test when a condition holds (a missing dependency, the wrong OS), and @pytest.mark.xfail records a test as "expected to fail" — useful for a known bug you haven't fixed yet, so a red build doesn't hide it but it also doesn't block you.
import pytest, sys
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only behavior")
def test_posix_path_handling():
assert "/" in "/etc/hosts"
@pytest.mark.xfail(reason="rounding bug tracked in #142")
def test_known_bug():
assert 0.1 + 0.2 == 0.3You can also invent your own markers (for example @pytest.mark.slow) and run subsets with pytest -m "not slow". Register custom markers in your pytest.ini or pyproject.toml so pytest doesn't warn about unknown names.
Handy command-line flags
A few flags make day-to-day work smoother: -v for verbose per-test output, -k "discount and not float" to run tests whose names match an expression, -x to stop at the first failure, --lf to re-run only the tests that failed last time, and -q for quiet output. Combining --lf -x during debugging tightens the feedback loop nicely.
Common pitfalls
A handful of traps catch people repeatedly. Don't assert exact equality on floats — use pytest.approx. Don't loop over cases inside one test — parametrize so each case reports independently. Don't share mutable state through a broad fixture scope unless you mean to. Don't patch functions at their definition site when the caller imported them by name. And avoid an over-broad pytest.raises(Exception): catch the specific type and match the message, or a bug that raises the wrong error will slip through green.
Wrap-up and next steps
pytest rewards you the moment you start: plain functions and plain assert get you a running suite in minutes, and fixtures, parametrization, and built-in helpers like tmp_path and monkeypatch scale that suite up without turning it into boilerplate. Write the test names so they read like a spec, keep each test independent, and let fixtures carry the setup.
From here, explore fixture parametrization (a fixture can itself be parametrized to multiply your coverage), pytest-cov for measuring which lines your tests actually exercise, and pytest-xdist for running the suite across multiple cores. But the fundamentals in this post will handle the large majority of what you write day to day — start there, and grow the toolbox as real needs appear.