pytest / Chapter 3 of 4

Plugins, debugging, and advanced fixtures

Build advanced pytest fixtures and parametrization, customize collection, and write plugins. Debug failures and produce CI test reports.

7 topicsUpdated
18 / 26

Advanced parametrization#

Indirect parameters configure fixtures at test setup time; fixture parameters repeat consumers automatically.

indirect=True passes parameter values to a fixture through request.param. Use this when setup is expensive or must happen after collection. Ordinary parameters are chosen during collection, before fixtures run. pytest.param(..., marks=...) can skip or xfail one row only.

PythonIndirect setup and per-row mark
import pytest


@pytest.fixture
def storage(request):
    backend = request.param
    instance = create_storage(backend)
    yield instance
    instance.close()


@pytest.mark.parametrize(
    "storage",
    [
        pytest.param("memory", id="fast"),
        pytest.param("disk", marks=pytest.mark.integration, id="disk"),
    ],
    indirect=True,
)
def test_roundtrip(storage):
    storage.put("token", b"abc")
    assert storage.get("token") == b"abc"

The fixture itself can declare params=...; every test using it runs for each value. Use ids for readable names. For project-specific dynamic cases, implement pytest_generate_tests(metafunc) in conftest.py: run metafunc.parametrize for a matching fixture name and source inputs from CLI options or a file.

PythonParameterized fixture and dynamic collection
@pytest.fixture(params=["memory", "disk"], ids=lambda name: f"backend={name}")
def backend(request):
    return request.param


def pytest_generate_tests(metafunc):
    if "region" in metafunc.fixturenames:
        regions = metafunc.config.getoption("--regions").split(",")
        metafunc.parametrize("region", regions, ids=regions)

Define --regions in pytest_addoption (see writing plugins below). Avoid a huge Cartesian product that makes a small suite unexpectedly slow. A parameter list that is empty does not silently pass: configure empty_parameter_set_mark when that case matters.

19 / 26

Advanced fixture patterns#

Factories, overrides and request keep shared setup flexible without coupling every test to one shape.

A factory fixture returns a function so one test can create several distinct objects. A test module may override a fixture from conftest.py by defining one with the same name closer to the test. The request object exposes the current node and configuration.

PythonFactory fixture
import itertools
import pytest


@pytest.fixture
def make_user():
    ids = itertools.count(1)

    def create(email=None):
        n = next(ids)
        return {"id": n, "email": email or f"user{n}@example.test"}

    return create


def test_transfer_has_distinct_users(make_user):
    sender = make_user(email="sender@example.test")
    recipient = make_user()
    assert sender["id"] != recipient["id"]
PythonRequest-aware fixture and autouse
@pytest.fixture
def audit_context(request):
    return {"test_name": request.node.name, "root": request.config.rootpath}


@pytest.fixture(autouse=True)
def clear_feature_cache():
    feature_cache.clear()
    yield
    feature_cache.clear()

Be selective with autouse: it hides dependencies from test signatures, and a session-wide autouse fixture can slow every test. Fixture ordering follows dependencies, scope and autouse rules; do not rely on definition order. pytest --setup-show prints the actual setup/teardown sequence.

20 / 26

Write a small plugin#

Hooks are special functions that pytest calls at defined stages; start in conftest.py.

A conftest.py can declare CLI options, register markers, and change collection. Reusable plugins are Python modules loaded by -p package.plugin or installed via the pytest11 entry point. Hooks usually receive explicit arguments matching their hook specification.

Pythonconftest.py — custom option and selection
import pytest


def pytest_addoption(parser):
    parser.addoption("--run-network", action="store_true", default=False,
                     help="run tests marked network")


def pytest_collection_modifyitems(config, items):
    if config.getoption("--run-network"):
        return
    skip = pytest.mark.skip(reason="requires --run-network")
    for item in items:
        if "network" in item.keywords:
            item.add_marker(skip)


def pytest_report_header(config):
    return f"project root: {config.rootpath}"
ShellUse the custom switch
python -m pytest -m network -rs
python -m pytest -m network --run-network
python -m pytest -p myproject.pytest_plugin

Register the network marker in config to avoid unknown-marker errors. Other common hooks include pytest_configure, pytest_runtest_makereport, and pytest_sessionfinish. Hook wrappers and report mutation are specialized: consult the hook reference and keep plugins small.

21 / 26

Discovery, import modes and namespace#

Collection determines which files are tests and how Python imports them.

pytest recurses from the requested path, applies naming patterns, and imports each collected module. --collect-only shows the exact node IDs without running tests. When the wrong module is imported or two tests have the same module name, examine package layout and import mode.

ShellDiagnose collection
python -m pytest --collect-only -q
python -m pytest --ignore=tests/slow tests/
python -m pytest --import-mode=importlib tests/
python -m pytest --trace-config --collect-only

The default import mode is prepend, which adds test locations to sys.path during imports. importlib avoids some path changes, but tests may no longer import one another as bare modules; keep helpers in importable packages. rootdir is pytest's collection/config anchor and does not automatically put that path on sys.path.

TOMLCustomize discovery only when necessary
[tool.pytest.ini_options]
python_files = ["test_*.py", "*_spec.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
norecursedirs = [".git", "build", "dist"]

Prefer normal Python package installation (such as an editable install) when testing a package. Avoid fixing import failures by relying on the current working directory in every test.

22 / 26

Debugging and speed#

Narrow a failure, inspect fixture setup, and report slow tests before optimizing.

--pdb enters Python's debugger at a failure; --trace starts the debugger at the beginning of each test. -x avoids scrolling past the first failing cause. --full-trace shows all frames, including those pytest normally hides.

ShellDebugging commands
python -m pytest -x --pdb tests/test_orders.py
python -m pytest --trace tests/test_orders.py::test_refund
python -m pytest --setup-show -vv tests/test_orders.py
python -m pytest --durations=20 --durations-min=0.05
python -m pytest --lf --tb=long

Use --durations=0 to report every test duration. When a suite is slow, measure tests before parallelizing: excessive fixtures, live network calls and repeated DB setup are common causes. With pytest-xdist, -n auto parallelizes across worker processes; isolation matters more than order.

PythonPrint only during local diagnosis

def test_refund_debug(client, capsys):
    response = client.post("/refund", json={"order_id": 8})
    print(response.json)
    assert response.status_code == 200

Failure output normally includes captured stdout. -s shows it live; remove temporary prints once the behavior is understood.

23 / 26

unittest and doctests#

Run existing unittest classes with pytest, and optionally collect examples embedded in docstrings.

pytest collects unittest.TestCase methods and reports them in the same run. Standard fixture injection into TestCase method parameters is not supported; use @pytest.mark.usefixtures for setup side effects, or migrate tests to plain pytest functions. unittest.mock works in either style.

PythonAn existing unittest test
import unittest


class TestLegacyParser(unittest.TestCase):
    def test_empty(self):
        self.assertEqual("  ".strip(), "")

Doctests run code examples in docstrings as interactive Python sessions. Enable collection explicitly; keep examples deterministic. doctest_namespace makes names available to doctests in the current test scope.

PythonModule example collected by --doctest-modules

def slug(text):
    """Return a simple slug.

    >>> slug("  New Order ")
    'new-order'
    """
    return text.strip().lower().replace(" ", "-")
ShellCompatibility commands
python -m pytest tests/test_legacy.py
python -m pytest --doctest-modules app/
python -m pytest --doctest-glob='*.rst' docs/
24 / 26

Results, reports and CI#

Use exit codes for automation, and machine-readable reports for CI artifacts.

A failed assertion is FAILED; fixture/setup/import exceptions are ERROR; SKIPPED never ran its body; XFAIL is an expected failure; XPASS is an unexpected pass. With strict=True, XPASS fails the run. pytest --help lists active plugin options too.

ShellCI-friendly outputs
python -m pytest -q -ra --junitxml=reports/pytest.xml
python -m pytest --maxfail=1 --strict-markers
python -m pytest --cov=app --cov-report=xml --cov-fail-under=85

--junitxml is built in. Coverage flags in the third line require pytest-cov. An absolute coverage threshold is a policy decision; it does not replace tests of edge cases. CLI exit status 0 is success, 1 means failures, 2 interruption, 3 internal error, 4 usage error, 5 no tests collected.

PythonTightening warnings for one test
import pytest


@pytest.mark.filterwarnings("error::DeprecationWarning")
def test_no_deprecated_api():
    assert calculate_total() >= 0