Pytest Plugins and Hooks
Add your own command-line flags, modify collection, react to test results, and package the whole thing as an installable pytest plugin.
Everything pytest does to a test — collecting it, running it, reporting it — passes through
a named hook. Define a function with that name in conftest.py and pytest calls yours too.
Adding a command-line flag
Marking slow tests only helps if there is a convenient way to exclude them. Rather than
remembering -m "not slow", invert the default:
# conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow",
action="store_true",
default=False,
help="run tests marked slow (they are skipped by default)",
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: takes more than a second")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# test_import.py
import pytest
def test_parses_a_row():
assert parse("A1,2") == ("A1", 2)
@pytest.mark.slow
def test_imports_10k_rows(tmp_path):
assert bulk_import(make_csv(tmp_path, rows=10_000)) == 10_000
$ pytest -q
.s [100%]
1 passed, 1 skipped in 0.03s
$ pytest --runslow -q
.. [100%]
2 passed in 6.41s
Three hooks, each doing one thing. pytest_addoption registers the flag,
pytest_configure registers the marker so --strict-markers stays happy, and
pytest_collection_modifyitems attaches a skip mark to every slow test unless the flag was
given. The fast path is now the default, and nobody has to remember an incantation.
pytest_addoption only works in the root conftest.py — pytest reads command-line
options before it discovers nested conftest files.
Reading the option from a fixture
# conftest.py
@pytest.fixture(scope="session")
def api_base_url(request):
return request.config.getoption("--api-url")
def pytest_addoption(parser):
parser.addoption("--api-url", default="http://localhost:8000",
help="base URL the API tests hit")
def test_health_endpoint(api_base_url):
assert requests.get(f"{api_base_url}/health").json()["status"] == "ok"
$ pytest -q
. [100%]
1 passed in 0.09s
$ pytest --api-url https://staging.example.com -q
. [100%]
1 passed in 0.84s
request.config is the same config object the hooks receive. Options can also come from the
ini file with parser.addini, which is how you let a project set a default without typing it
every run.
Generating tests dynamically
pytest_generate_tests runs for every collected function and can parametrize it from
anything — a flag, a fixtures file, a database:
# conftest.py
def pytest_addoption(parser):
parser.addoption("--browser", action="append", default=[],
help="browser to run against (repeatable)")
def pytest_generate_tests(metafunc):
if "browser" in metafunc.fixturenames:
browsers = metafunc.config.getoption("browser") or ["chromium"]
metafunc.parametrize("browser", browsers)
def test_home_page_loads(browser):
page = launch(browser)
assert page.title() == "Shop"
$ pytest -v
test_ui.py::test_home_page_loads[chromium] PASSED [100%]
1 passed in 1.92s
$ pytest --browser chromium --browser firefox -v
test_ui.py::test_home_page_loads[chromium] PASSED [ 50%]
test_ui.py::test_home_page_loads[firefox] PASSED [100%]
2 passed in 3.71s
The test never mentions a browser list. CI runs the matrix, your laptop runs one — same file.
metafunc.fixturenames is the guard that keeps the hook from touching unrelated tests.
Knowing whether the test failed
Fixtures cannot see the result of the test they set up, because teardown runs the same way whether it passed or failed. This hook makes the report available:
# conftest.py
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
setattr(item, f"rep_{report.when}", report)
The wrapper lets pytest build the report, then stores it on the test item as rep_setup,
rep_call and rep_teardown. Now a fixture can act on the outcome:
@pytest.fixture
def workspace(tmp_path, request):
yield tmp_path
if request.node.rep_call.failed:
print(f"\nFAILED — artifacts kept in {tmp_path}")
for path in sorted(tmp_path.rglob("*")):
print(f" {path.relative_to(tmp_path)}")
$ pytest -q
F [100%]
============================== FAILURES ===============================
_________________________ test_writes_manifest ________________________
> assert (workspace / "manifest.json").exists()
E assert False
--------------------------- Captured stdout teardown ------------------
FAILED — artifacts kept in /tmp/pytest-of-you/pytest-31/test_writes_manifest0
input.csv
output/
output/rows.parquet
1 failed in 0.14s
The manifest is missing and you can see the three files that were produced, without
re-running anything. This is the pattern behind every screenshot-on-failure plugin: browser
fixtures replace the print with page.screenshot(...).
Newer pytest also accepts @pytest.hookimpl(wrapper=True), where you return the value the
yield produced instead of calling outcome.get_result(). Both work in pytest 8;
hookwrapper=True is what you will find in existing code.
Changing what the report says
def pytest_report_header(config):
return [f"api url: {config.getoption('--api-url')}",
f"git rev: {git_revision()}"]
$ pytest
========================= test session starts =========================
platform linux -- Python 3.11.9, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/you/shop
configfile: pyproject.toml
api url: https://staging.example.com
git rev: 7f3a91c
plugins: cov-6.0.0, xdist-3.6.1
collected 24 items
Two lines that turn an archived CI log into evidence — which environment, which commit. It costs nothing and pays off the first time someone asks what a failing run was actually pointed at.
Ordering and deselecting
pytest_collection_modifyitems gets the real list, so it can also reorder:
def pytest_collection_modifyitems(session, config, items):
"""Run unit tests before integration tests, whatever the file order."""
items.sort(key=lambda item: "integration" in item.keywords)
$ pytest -v
tests/test_orders.py::test_total PASSED [ 25%]
tests/test_shipping.py::test_rates PASSED [ 50%]
tests/test_api.py::test_checkout PASSED [ 75%]
tests/test_api.py::test_refund PASSED [100%]
Fast feedback first: with -x, a broken unit test now stops the run before the slow
integration tests get a chance to start.
Packaging it as a plugin
When two repositories need the same fixtures, promote the conftest.py to a package:
pytest-shopkit/
├── pyproject.toml
└── src/pytest_shopkit/
├── __init__.py
└── plugin.py
# pyproject.toml
[project]
name = "pytest-shopkit"
version = "0.1.0"
dependencies = ["pytest>=8.0"]
[project.entry-points.pytest11]
shopkit = "pytest_shopkit.plugin"
plugin.py holds exactly what conftest.py held — the fixtures and hooks, unchanged.
pip install -e ../pytest-shopkit
pytest
========================= test session starts =========================
platform linux -- Python 3.11.9, pytest-8.3.4, pluggy-1.5.0
plugins: cov-6.0.0, shopkit-0.1.0
collected 24 items
The pytest11 entry point is the whole registration mechanism — pytest loads every installed
distribution that declares one. That is how pytest-cov and pytest-mock appear without any
import, and how your fixtures now do too.
$ pytest --fixtures | grep -A2 workspace
workspace [session scope] -- pytest_shopkit/plugin.py:41
A temp directory whose contents are listed if the test fails.
Disable a misbehaving plugin for one run with -p no:shopkit — useful for proving that a
plugin, not your code, is causing a failure.
Practice
1. Add a --runslow flag and confirm slow tests skip by default.
$ pytest -rs -q
.s [100%]
SKIPPED [1] needs --runslow
1 passed, 1 skipped in 0.03s
-rs shows the reason, which doubles as the instruction for enabling it. A skip whose reason
names the flag is self-documenting.
2. Add a pytest_report_header returning the current git branch.
rootdir: /home/you/shop
branch: feature/bulk-discount
collected 24 items
Cheap provenance. When a CI artefact is the only record of a run, the header is where you put what you will wish you knew.
3. Use pytest_generate_tests to parametrize from a JSON file.
def pytest_generate_tests(metafunc):
if "case" in metafunc.fixturenames:
cases = json.loads(Path("tests/cases.json").read_text())
metafunc.parametrize("case", cases, ids=[c["name"] for c in cases])
test_rules.py::test_rule[free-over-10kg] PASSED [ 50%]
test_rules.py::test_rule[express-doubles] PASSED [100%]
Non-programmers can add a case by editing JSON. The ids come from the data, so the report
still names each case.
4. Store the test report on the item and print the outcome during teardown.
--------------------------- Captured stdout teardown ------------------
FAILED — artifacts kept in /tmp/pytest-of-you/pytest-31/test_writes_manifest0
Nothing is printed for a passing test, because rep_call.failed is false. Conditional
teardown output is how you get diagnostics on failure without drowning a green run in noise.
Next: making a large suite fast and keeping it deterministic.