Pytest in Practice: Fixtures, Parametrization, and Mocking
Go beyond `assert` and learn pytest the way professionals use it: clean fixtures for setup and teardown, parametrization to kill duplicated tests, and monkeypatch/mock to isolate code from the network and filesystem.
Most Python developers start writing tests the same way: a script full of print() calls and a hopeful eyeball over the output. It works until it doesn't. The moment your project grows past a few functions, you need something that runs every check automatically, tells you exactly what broke, and doesn't drown you in boilerplate. That tool is pytest — the de facto standard for testing in the Python ecosystem.
pytest's appeal is that simple things stay simple. You write plain functions, use plain assert, and the runner does the rest. But underneath that friendly surface is a powerful fixture system, table-driven parametrization, and clean ways to isolate your code from the messy outside world. This guide walks through all three, with runnable examples you can paste into a project today.
Getting started: plain functions and rich asserts
Install pytest into your virtual environment and you're ready:
python -m pip install pytestpytest discovers tests by convention. Files named test_*.py (or *_test.py), functions prefixed with test_, and classes prefixed with Test are all collected automatically. Say we have a small module, wallet.py:
class InsufficientFunds(Exception):
pass
class Wallet:
def __init__(self, balance=0):
self.balance = balance
def add_cash(self, amount):
self.balance += amount
def spend_cash(self, amount):
if amount > self.balance:
raise InsufficientFunds(
f"Not enough: have {self.balance}, need {amount}"
)
self.balance -= amountA first test file, test_wallet.py, needs nothing more than the built-in assert:
from wallet import Wallet, InsufficientFunds
def test_starts_with_balance():
w = Wallet(balance=20)
assert w.balance == 20
def test_add_cash():
w = Wallet(balance=20)
w.add_cash(80)
assert w.balance == 100Run it from your project root:
python -m pytest -qUnlike unittest, you never reach for self.assertEqual or self.assertTrue. pytest rewrites the bytecode behind assert so that when a check fails, it shows you both sides of the comparison — the expected value and the actual one — without any extra effort on your part. That introspection is one of the biggest day-to-day quality-of-life wins.
Fixtures: setup and teardown without the ceremony
Notice that both tests above rebuilt a Wallet by hand. As tests multiply, that repetition becomes a liability. A fixture is pytest's answer: a function that produces a piece of test state, which pytest injects into any test that asks for it by name.
import pytest
from wallet import Wallet, InsufficientFunds
@pytest.fixture
def wallet():
return Wallet(balance=20)
def test_starts_with_balance(wallet):
assert wallet.balance == 20
def test_spend_cash(wallet):
wallet.spend_cash(10)
assert wallet.balance == 10Each test that lists wallet as a parameter gets a fresh object, because the fixture function runs again for every test. That isolation matters — one test can never corrupt state for another.
Setup and teardown with yield
Fixtures shine when a resource needs cleanup. Use yield instead of return: everything before the yield is setup, everything after is teardown, and pytest guarantees the teardown runs even if the test fails.
@pytest.fixture
def db_connection():
conn = connect_to_test_db() # setup
yield conn # hand the value to the test
conn.close() # teardown, always runsScope: share expensive setup
By default a fixture is recreated for every test (scope="function"). If a resource is expensive to build and safe to share, widen the scope so it's created once per module or per session:
@pytest.fixture(scope="session")
def http_client():
client = ExpensiveClient()
yield client
client.shutdown()Built-in fixtures you'll use constantly
pytest ships with fixtures that solve common problems. tmp_path gives you a unique, automatically-cleaned temporary directory as a pathlib.Path, so tests that touch the filesystem never collide or leave junk behind:
import json
def test_tmp_path_roundtrip(tmp_path):
p = tmp_path / "data.json"
p.write_text(json.dumps({"n": 42}))
assert json.loads(p.read_text())["n"] == 42Parametrization: one test, many cases
When you find yourself copy-pasting a test and changing only the numbers, that's the signal to parametrize. The @pytest.mark.parametrize decorator runs the same test body once per row of data, and — crucially — reports each row as a separate test, so you see exactly which case failed.
import pytest
from wallet import Wallet
@pytest.mark.parametrize("start, spend, expected", [
(100, 40, 60),
(50, 50, 0),
(30, 10, 20),
])
def test_spend_table(start, spend, expected):
w = Wallet(balance=start)
w.spend_cash(spend)
assert w.balance == expectedThat single function becomes three independent tests. Add a fourth scenario by adding one line — no new function, no duplicated assertions. You can stack multiple parametrize decorators to get the cartesian product of inputs, which is handy for matrix-style checks.
Asserting that code raises
Testing the unhappy path is just as important as the happy one. The pytest.raises context manager asserts that a block raises a specific exception, and the optional match argument checks the message against a regex:
def test_overspend_raises():
w = Wallet(balance=20)
with pytest.raises(InsufficientFunds, match="Not enough"):
w.spend_cash(100)If the block does not raise, or raises something else, the test fails — exactly what you want.
Isolating from the outside world: monkeypatch and mock
Good unit tests don't hit the network, read real config, or depend on the system clock. pytest's monkeypatch fixture lets you temporarily replace attributes, environment variables, and dictionary entries, and it undoes every change automatically when the test ends.
Suppose a module reads configuration from the environment:
# config.py
import os
def get_api_url():
return os.environ.get("API_URL", "https://api.default.test")You can set the variable for the duration of a single test without polluting your real environment:
import config
def test_monkeypatch_env(monkeypatch):
monkeypatch.setenv("API_URL", "https://api.prod.test")
assert config.get_api_url() == "https://api.prod.test"Faking a network call
The same technique replaces functions. Imagine a function that calls a weather API with requests:
# weather.py
import requests
def current_temp(city):
resp = requests.get(f"https://api.weather.test/{city}")
resp.raise_for_status()
return resp.json()["temp_c"]In the test, swap out requests.get for a stand-in that returns a canned response. Note the patch target: you patch the name where it is looked up (weather.requests.get), not where it's defined.
from weather import current_temp
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
def test_current_temp_mocked(monkeypatch):
def fake_get(url):
assert url.endswith("/paris")
return FakeResponse({"temp_c": 21})
monkeypatch.setattr("weather.requests.get", fake_get)
assert current_temp("paris") == 21If you prefer the standard library's mocking tools, unittest.mock.patch integrates seamlessly and adds call-assertions for free:
from unittest.mock import patch
from weather import current_temp
def test_with_unittest_mock():
with patch("weather.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"temp_c": 9}
mock_get.return_value.raise_for_status.return_value = None
assert current_temp("oslo") == 9
mock_get.assert_called_once_with("https://api.weather.test/oslo")Sharing fixtures with conftest.py
Once a fixture is useful to more than one test file, move it into a conftest.py at the root of your test directory. pytest discovers it automatically — no imports needed — and makes every fixture inside available to every test below it. This is the idiomatic place for database connections, test clients, and seeded sample data.
# conftest.py
import pytest
from wallet import Wallet
@pytest.fixture
def funded_wallet():
return Wallet(balance=1000)Common pitfalls
A few traps catch newcomers. First, patch where the object is used, not where it's defined — patching requests.get globally often misses the local reference your module already imported. Second, avoid fixtures with side effects that leak across tests; if a test mutates a shared, session-scoped object, later tests may fail mysteriously, so keep shared fixtures read-only or reset them in teardown. Third, don't over-mock: if you replace so much of your code that the test only exercises your mocks, it proves nothing. Mock the boundaries (network, disk, clock), and let real logic run. Finally, remember that parametrize ids can be customized with the ids= argument so failure output reads clearly instead of showing raw tuples.
Wrap-up and next steps
You now have the core of professional pytest: plain assert with rich failure output, fixtures for clean setup and teardown, scopes to share expensive resources, parametrization to collapse duplicated tests, and monkeypatch plus mock to isolate your code from the network and filesystem. Together these cover the vast majority of everyday testing.
From here, explore a few high-value additions: run pytest --cov with the pytest-cov plugin to measure coverage, use markers like @pytest.mark.slow with -m to select subsets of tests, add pytest-xdist to run your suite across multiple CPU cores, and wire python -m pytest into your CI pipeline so every push is checked automatically. Start by parametrizing one repetitive test in your current project — the payoff is immediate, and it's the fastest way to make the habit stick.