Tests You Can Trust: A Practical Deep Dive into pytest Fixtures, Parametrization, and Mocking

Learn how to write pytest suites that stay fast and readable as they grow: fixtures with proper teardown, parametrization instead of copy-paste, built-in helpers like tmp_path and monkeypatch, and mocking that verifies behaviour rather than faking success.

Tests You Can Trust: A Practical Deep Dive into pytest Fixtures, Parametrization, and Mocking

Most Python projects reach a point where the test suite becomes the problem. It started as a handful of assert statements, and now it's two thousand lines of near-identical functions, each one building its own throwaway objects, each one subtly different for reasons nobody remembers. Adding a feature means editing fifteen tests. Nobody trusts the failures.

pytest fixes most of this, but only if you use the parts that matter. This article walks through the four that carry the most weight — fixtures, parametrization, the built-in helpers, and mocking — using one small example domain throughout so you can see how they compose.

The code under test

Everything below tests this module. It's deliberately small, but it has the things real code has: a dependency, a couple of error paths, and some arithmetic worth checking.

# shop.py
class InsufficientStock(Exception):
    pass


class Cart:
    def __init__(self, catalog):
        self.catalog = catalog
        self.items = {}

    def add(self, sku, qty=1):
        if qty <= 0:
            raise ValueError("qty must be positive")
        available = self.catalog.stock(sku)
        if self.items.get(sku, 0) + qty > available:
            raise InsufficientStock(sku)
        self.items[sku] = self.items.get(sku, 0) + qty

    def total(self):
        return sum(self.catalog.price(sku) * n for sku, n in self.items.items())

Note that Cart takes its catalog as a constructor argument rather than importing one. That single decision is what makes the rest of this article easy. Code that receives its dependencies is code you can test without patching anything.

Fixtures: setup as a dependency graph

A fixture is a function that produces a value, marked with @pytest.fixture. Any test that names it as a parameter gets that value. The key insight is that fixtures can depend on other fixtures, so setup becomes a small dependency graph that pytest resolves for you.

# test_shop.py
import pytest
from shop import Cart, InsufficientStock


class FakeCatalog:
    def __init__(self, data):
        self.data = data

    def price(self, sku):
        return self.data[sku][0]

    def stock(self, sku):
        return self.data[sku][1]


@pytest.fixture
def catalog():
    return FakeCatalog({"apple": (1.50, 10), "pear": (2.00, 1)})


@pytest.fixture
def cart(catalog):
    return Cart(catalog)


def test_total_of_empty_cart_is_zero(cart):
    assert cart.total() == 0

The test asks for cart; pytest sees that cart needs catalog, builds that first, and hands over a ready object. Fixtures are re-created for every test by default, so one test can never pollute another.

Teardown with yield

When a fixture needs cleanup, yield the value instead of returning it. Everything after the yield runs once the test finishes — including when it fails.

@pytest.fixture
def db_connection():
    conn = connect(":memory:")
    yield conn
    conn.close()

This is strictly better than setUp/tearDown pairs, because the setup and its matching cleanup sit next to each other in one readable block.

Scope, and the trap that comes with it

If a fixture is genuinely expensive — spinning up a container, loading a large model — you can widen its scope so it's built once per module or per session:

@pytest.fixture(scope="session")
def expensive_index():
    return build_search_index()

The pitfall: a session-scoped fixture that returns a mutable object is shared state. One test appends to a list, and a later test sees it. If you widen scope, make the object immutable, or reset it in a narrower fixture that wraps it. When in doubt, keep the default function scope — correctness beats a few saved milliseconds.

conftest.py

Fixtures defined in a file called conftest.py are available to every test in that directory and below, with no import needed. Put your shared fixtures there and keep test files focused on assertions.

Parametrization: one test, many cases

The most common source of test-suite bloat is copy-pasting a test to change one input. @pytest.mark.parametrize collapses those into a single function that pytest runs once per case, reporting each as a separate result.

@pytest.mark.parametrize(
    "sku, qty, expected",
    [("apple", 1, 1.50), ("apple", 3, 4.50), ("pear", 1, 2.00)],
)
def test_total_after_add(cart, sku, qty, expected):
    cart.add(sku, qty)
    assert cart.total() == pytest.approx(expected)

Two details worth stealing. First, parametrization composes with fixtures — cart is still injected, and still rebuilt fresh for each case. Second, pytest.approx handles float comparison. Writing assert cart.total() == 4.50 against accumulated floats is how you get a test that fails on a machine you don't own.

You can also name your cases with ids, which turns cryptic output like test_factorial[0-1] into something readable:

@pytest.mark.parametrize(
    "n, expected",
    [(0, 1), (1, 1), (5, 120)],
    ids=["zero", "one", "five"],
)
def test_factorial(n, expected):
    assert math.factorial(n) == expected

Testing the failure paths

Half of any real API is what it does when things go wrong, and that half is usually undertested. pytest.raises makes error paths as cheap to assert as happy ones.

def test_overselling_raises(cart):
    with pytest.raises(InsufficientStock) as excinfo:
        cart.add("pear", 2)
    assert "pear" in str(excinfo.value)


@pytest.mark.parametrize("bad", [0, -1])
def test_non_positive_qty_rejected(cart, bad):
    with pytest.raises(ValueError, match="positive"):
        cart.add("apple", bad)

The match= argument is a regex applied to the exception message. Use it. A bare pytest.raises(ValueError) will happily pass when your code raises a completely different ValueError than the one you meant to test — including one from a typo in the test itself.

Built-in fixtures worth knowing

pytest ships with fixtures that remove most reasons to write your own infrastructure.

tmp_path gives each test a fresh pathlib.Path directory, cleaned up automatically. No more /tmp/test123 left behind:

import json


@pytest.fixture
def config_file(tmp_path):
    p = tmp_path / "config.json"
    p.write_text(json.dumps({"retries": 3}), encoding="utf-8")
    return p


def test_config_roundtrip(config_file):
    assert load_config(config_file)["retries"] == 3

monkeypatch sets environment variables, attributes, and dict entries, then undoes every change when the test ends:

def test_env_override(monkeypatch):
    monkeypatch.setenv("APP_RETRIES", "7")
    monkeypatch.setattr("myapp.client.TIMEOUT", 0.1)
    assert load_settings().retries == 7

caplog captures log records so you can assert on them instead of eyeballing console output:

import logging


def test_logging_captured(caplog):
    with caplog.at_level(logging.WARNING, logger="worker"):
        do_work()
    assert "disk almost full" in caplog.text

There's also capsys for stdout/stderr and tmp_path_factory for session-scoped directories.

Mocking, without lying to yourself

Mock the things you don't control — network calls, clocks, payment gateways — and nothing else. The point of a mock is to assert on the interaction, not just to make the code run.

from unittest.mock import Mock


def fetch_user(client, user_id):
    resp = client.get(f"/users/{user_id}")
    resp.raise_for_status()
    return resp.json()["name"]


def test_fetch_user_calls_endpoint():
    resp = Mock()
    resp.json.return_value = {"name": "Ada"}
    client = Mock()
    client.get.return_value = resp

    assert fetch_user(client, 42) == "Ada"
    client.get.assert_called_once_with("/users/42")
    resp.raise_for_status.assert_called_once()

Those last two assertions are the valuable ones. Without them, the test would still pass if you built the URL wrong or forgot to check the status code.

Two pitfalls. A plain Mock accepts any attribute or method call, so a test can keep passing after you rename a method on the real object. Use Mock(spec=RealClass) — or better, a small hand-written fake like FakeCatalog above — so the double is constrained by the real interface. And when you patch, patch where the name is looked up, not where it's defined: if myapp.service does from myapp.api import fetch, you patch myapp.service.fetch.

A few habits that pay off

  • Run pytest -x --lf while iterating: stop at the first failure, and on the next run start with the tests that failed last time.
  • pytest -q for a quiet summary; pytest -k "cart and not slow" to select by name.
  • Prefer several small assertions with clear names over one test that checks everything. pytest's assertion rewriting shows you the actual values, so assert cart.total() == 4.50 already tells you what went wrong.
  • Put shared config in pyproject.toml under [tool.pytest.ini_options] so everyone runs the same flags.

Wrap-up and next steps

The through-line here is that good tests come from testable design. Cart was easy to test because it accepts its catalog rather than reaching for a global — and that made fixtures, parametrization, and fakes fall into place naturally. When a test is painful to write, that pain is usually feedback about the code, not about the framework.

From here, three directions are worth exploring: pytest-cov to find the branches nobody exercises, pytest-xdist to run the suite across cores once it gets slow, and Hypothesis for property-based testing when you'd rather describe the rules than enumerate the cases.