Testing with pytest
Fixtures, parametrize, mocks, temporary files and coverage.
Create a free account Try part of it here
Why it matters
The testing lesson showed why automated tests matter. pytest is the tool nearly every Python project uses to write them: plain functions and plain assert statements, with powerful helpers for setup, repetition, fake files and fake services. Knowing it well means you can test code that touches the clock, the network and the disk, and that's most real code. It's also how you'll check that the AI features you build keep working.
What you'll be able to do
- Write pytest tests with plain assert, pytest.raises and pytest.approx
- Share setup with fixtures and conftest.py, and cover many cases with parametrize
- Replace slow or unpredictable parts with monkeypatch and mocks, and use tmp_path and capsys
- Judge a test suite by whether it catches bugs, and read coverage sensibly
What it covers
- Tests are functions that assert
- Fixtures: shared setup
- Many cases, one test: parametrize
- Faking the slow and unpredictable parts
- How good are your tests?
- Worked example: tmppath, capsys and a fixture together
Tests are functions that assert
A pytest test is a function whose name starts with test_, in a file whose name starts with test_. Inside, a plain assert states what should be true. pytest finds the tests, runs each one, and when an assert fails it shows the values on both sides:
from pathlib import Path import pytest Path("shipping.py").write_text(''' def shipping_cost(weight_kg, express=False): if weight_kg <= 0: raise ValueError("weight must be positive") base = 15 + 2 * weight_kg if express else 6 + weight_kg return round(base, 2) ''') Path("test_shipping.py").write_text(''' import pytest from shipping import shipping_cost def test_standard(): assert shipping_cost(3) == 9 def test_express_costs_more(): assert shipping_cost(3, express=True) > shipping_cost(3) def test_fractional_weights(): assert shipping_cost(0.1 + 0.2) == pytest.approx(6.3) def test_rejects_zero_weight(): with pytest.raises(ValueError, match="positive"): shipping_cost(0) def test_this_one_is_wrong(): assert shipping_cost(10) == 15 ''') code = pytest.main(["-q", "-p", "no:cacheprovider", "test_shipping.py"]) print("exit code:", code)
In a real project these are two files in a folder, and you'd type pytest -q in a terminal; here one cell writes both files and runs pytest. Read the failure: pytest shows the assertion with the actual value (16), so you can see at once whether the code or the test is wrong. Here the test is wrong: 6 + 10 is 16.
pytest.approx compares floats with a small tolerance, since 0.1 + 0.2 isn't exactly 0.3. pytest.raises checks that a block raises the expected exception, and match= checks its message too.
A few command-line options make everyday testing faster. pytest -x stops at the first failure, so you can fix one thing at a time. pytest -k discount runs only tests whose names contain "discount". pytest --lf reruns just the tests that failed last time. And pytest -v lists every test by name, which helps when you want to see exactly what ran.
Fixtures: shared setup
Many tests need the same starting point: a cart with two items, a temporary database, a logged-in user. A fixture is a function decorated with @pytest.fixture that builds it. Any test that names the fixture as a parameter gets a fresh one:
import pytest from cart import Cart @pytest.fixture def cart(): c = Cart() c.add("TE-101", price=200) c.add("CA-132", price=5, qty=2) return c def test_total(cart): assert cart.total() == 189.0 # 210 with 10% off for orders of 100 or more def test_remove(cart): cart.remove("CA-132") assert cart.total() == 180.0
Each test gets its own new cart, so test_remove can't affect test_total: tests stay independent and can run in any order. A fixture can yield instead of return, and the code after the yield runs as cleanup, like a context manager. Fixtures used by many test files go in a file called conftest.py, which pytest loads automatically, with no import needed.
pytest has built-in fixtures too. Name them as parameters and they appear: tmp_path (a fresh temporary folder for each test), capsys (what the code printed), monkeypatch (temporary changes, see below) and caplog (log records).
Many cases, one test: parametrize
When the same check applies to many inputs, list them instead of copying the test:
from pathlib import Path import pytest Path("discount.py").write_text(''' def discount(subtotal): return 0.1 if subtotal >= 100 else 0.0 ''') Path("test_discount.py").write_text(''' import pytest from discount import discount @pytest.mark.parametrize("subtotal, expected", [ (0, 0.0), (99.99, 0.0), (100, 0.1), (100.01, 0.1), (2500, 0.1), ]) def test_discount(subtotal, expected): assert discount(subtotal) == expected ''') pytest.main(["-q", "-p", "no:cacheprovider", "test_discount.py"])
Each row runs as its own test and is reported separately, so a failure names the exact input. Notice the rows crowd around 100. Bugs live at boundaries: >= written as >, an off-by-one in a range, an empty list. Always test the edge, just below it and just above it.
import pytest print(0.1 + 0.2 == 0.3, 0.1 + 0.2 == pytest.approx(0.3))
Show the answer
False True
Floats can't store 0.1 and 0.2 exactly, so the sum is 0.30000000000000004. approx compares with a small tolerance.
Faking the slow and unpredictable parts
Some code depends on things a test can't control: the clock, random numbers, environment variables, a real API. monkeypatch swaps something out for the length of one test and puts it back afterwards:
def test_uses_key_from_environment(monkeypatch): monkeypatch.setenv("SMS_KEY", "test-key") assert load_settings()["sms_key"] == "test-key" def test_report_date(monkeypatch): monkeypatch.setattr(report, "today", lambda: date(2025, 6, 30)) assert report.title() == "Report for June 2025"
For checking how something was called, unittest.mock provides mock objects that record every call made to them:
from unittest.mock import Mock def notify_if_cheaper(price, target, send): if price <= target: send(f"Now ${price:.2f}") return True return False send = Mock() assert notify_if_cheaper(356.25, 400, send) is True send.assert_called_once_with("Now $356.25") assert notify_if_cheaper(500, 400, send) is False print("calls recorded:", send.call_count, send.call_args_list)
Passing send in as a parameter, as in the alert-bot project, is what makes it easy to replace. Code that reaches out for its dependencies (calling requests.get deep inside) needs patching; code that receives them can simply be handed a fake. Don't overdo mocks, though: a test that mocks everything checks only that the code calls what you told it to call.
Use the lightest tool that works. Most tests need no fakes at all; fakes are for the parts you can't control.
How good are your tests?
A test suite's job is to catch bugs. Passing isn't the goal; a suite with no assertions passes too. Two ways to judge it:
- Coverage measures which lines the tests ran:
pytest --cov=cartwith the pytest-cov plugin, orcoverage run -m pytestthencoverage report. A line no test runs is certainly untested. But running a line doesn't mean checking it: 100% coverage with weak asserts can still miss every bug. Use coverage to find gaps, not as a score to hit. - Plant a bug and see if the tests notice. Change
>=to>, delete a validation, return the wrong value. If everything still passes, a test is missing. Tools called mutation testers do this automatically; the exercise below does it by hand, and checks your tests the same way.
Good tests are also fast, independent of each other and of the order they run in, and named for the behavior they check (test_discount_starts_at_exactly_100), so a failure explains itself. When a bug is reported, write a test that reproduces it first, watch it fail, then fix the code: that bug can never quietly return.
Worked example: tmp_path, capsys and a fixture together
Test a function that saves a report to a file and prints a summary, using built-in fixtures so the test touches no real files and captures the output.
from pathlib import Path import pytest Path("report.py").write_text(''' import json def save_report(rows, folder): path = folder / "report.json" path.write_text(json.dumps(rows)) print(f"saved {len(rows)} rows to {path.name}") return path ''') Path("test_report.py").write_text(''' import json import pytest from report import save_report @pytest.fixture def rows(): return [{"sku": "TE-101", "qty": 2}, {"sku": "PA-108", "qty": 1}] def test_saves_json(rows, tmp_path): path = save_report(rows, tmp_path) assert json.loads(path.read_text()) == rows def test_prints_summary(rows, tmp_path, capsys): save_report(rows, tmp_path) assert capsys.readouterr().out == "saved 2 rows to report.json\\n" ''') code = pytest.main(["-q", "-p", "no:cacheprovider", "test_report.py"]) print("exit code:", code)
tmp_path gives each test its own empty temporary folder, so the tests never write into the project or into each other's files. capsys.readouterr().out returns everything printed so far, so the summary line can be checked exactly. The rows fixture keeps the sample data in one place for both tests. Exit code 0 means every test passed; 1 means at least one failed, which is what CI checks.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why does each test that names a fixture get a fresh object?