Skip to main content
Pytest advanced Lesson 9 of 10

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.

Frequently Asked Questions

Where do pytest hooks go?
In conftest.py, or in a module registered as a plugin. Hooks are found by name — a function called pytest_addoption in conftest.py is called during startup with no registration needed. Only the root conftest.py can implement command-line hooks such as pytest_addoption.
What is pytest_collection_modifyitems for?
It receives the full list of collected tests before any of them run, so it can reorder them, deselect some, or attach marks. The usual use is skipping tests tagged slow unless a --runslow flag was passed.
How do I know whether the current test failed from inside a fixture?
Implement pytest_runtest_makereport as a hook wrapper and store the report on the item. A teardown fixture can then check request.node.rep_call.failed and keep artifacts, take a screenshot, or dump logs only when something went wrong.
How do I turn a conftest.py into a real plugin?
Move the code into a package module and declare a pytest11 entry point in pyproject.toml. Any environment that installs the package gets the fixtures and hooks automatically, and pytest lists it in the plugins line of the run header.