Plugins, debugging, and advanced fixtures
Build advanced pytest fixtures and parametrization, customize collection, and write plugins. Debug failures and produce CI test reports.
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.
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.
@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.
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.
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="[email protected]")
recipient = make_user()
assert sender["id"] != recipient["id"]@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.
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.
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}"python -m pytest -m network -rs
python -m pytest -m network --run-network
python -m pytest -p myproject.pytest_pluginRegister 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.
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.
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-onlyThe 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.
[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.
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.
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=longUse --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.
def test_refund_debug(client, capsys):
response = client.post("/refund", json={"order_id": 8})
print(response.json)
assert response.status_code == 200Failure output normally includes captured stdout. -s shows it live; remove temporary prints once the behavior is understood.
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.
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.
def slug(text):
"""Return a simple slug.
>>> slug(" New Order ")
'new-order'
"""
return text.strip().lower().replace(" ", "-")python -m pytest tests/test_legacy.py
python -m pytest --doctest-modules app/
python -m pytest --doctest-glob='*.rst' docs/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.
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.
import pytest
@pytest.mark.filterwarnings("error::DeprecationWarning")
def test_no_deprecated_api():
assert calculate_total() >= 0Help improve CodeRef
Allow Google Analytics to measure page visits and use of the guides? It uses cookies after you accept. Your choice won’t affect access to the site. Privacy notice