Pytest & CI

Writing a pytest Hookwrapper for Test Reports

Standard pytest reporting tells you a test failed and prints its traceback. It does not tell you which feature flag was set, which database the fixture provisioned, or how much memory the process had grown by — and by the time teardown runs, the report has already been rendered. A hookwrapper around pytest_runtest_makereport is the supported way to intercept every report before it is emitted, enrich it, and make the outcome visible to fixtures that need to react to failure.

Prerequisites

  • pytest 7.0+ for the hookimpl(hookwrapper=True) form used here (pytest 8 also accepts the newer wrapper=True style with a return instead of a post-yield block).
  • Plugin structure and registration from building custom pytest plugins.

Solution

The wrapper yields exactly once. Everything before the yield runs before the other implementations, and everything after it sees their result.

Python
# conftest.py or a plugin module
import pytest

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield                       # let pytest build the report first
    report = outcome.get_result()         # TestReport for ONE phase

    # Make the outcome of each phase reachable from the item, so fixtures can see it.
    setattr(item, f"rep_{report.when}", report)

    if report.when == "call" and report.failed:
        # Enrich the report with context nobody would otherwise capture.
        report.sections.append((
            "captured context",
            f"markers={[m.name for m in item.iter_markers()]}\n"
            f"fixtures={sorted(item.fixturenames)}",
        ))

Two details make this correct rather than merely working. outcome.get_result() must be called after the yield — before it, no implementation has run and there is no report. And report.when distinguishes the three phases pytest reports per test, so unfiltered logic runs three times and, for a test that fails in setup, runs against a call report that does not exist.

With the report attached to the item, a fixture can branch on the outcome during its own teardown — the standard way to dump diagnostics only for failures:

Python
import pytest

@pytest.fixture
def browser(request):
    driver = start_browser()
    yield driver
    # request.node is the item the wrapper decorated above.
    report = getattr(request.node, "rep_call", None)
    if report is not None and report.failed:
        driver.save_screenshot(f"/artifacts/{request.node.name}.png")
    driver.quit()
Where a hookwrapper sits in the call chain A sequence diagram with three lanes: pytest core, the hookwrapper, and the normal hook implementations. Core calls the wrapper, the wrapper runs its pre-yield code and yields, the normal implementations build the report, control returns to the wrapper which reads and enriches the result, and only then does the report reach the terminal reporter. Where a hookwrapper sits in the call chain pytest core hookwrapper implementations call makereport yield: run them the built report enrich it report continues Everything after the yield runs with the result in hand — that is the whole point of the form.
The wrapper does not produce a report; it observes and mutates the one the real implementations produced.

Why this works

pytest's hook system calls every registered implementation for a hook and collects their results. A hookwrapper is not one of those implementations: pluggy runs it around the whole set, passing control at the yield and returning the collected outcome afterwards. That gives it two capabilities no plain implementation has — it sees the final result rather than contributing to it, and it can run code both before and after the entire chain. Because reports are created per phase, the wrapper is invoked three times per test, and report.when is the only reliable way to tell which invocation you are in.

The three reports pytest builds per test A left-to-right sequence of the three test phases, each producing its own report: setup, call and teardown, with a note that a setup failure means no call report is ever produced. The three reports pytest builds per test setup fixtures build call the test body runs teardown finalizers unwind A setup failure skips the call phase entirely — rep_call will not exist.
Each phase produces its own TestReport, so an unfiltered wrapper body executes three times per test.

Routing reports somewhere useful

Enriching the report is half the value; the other half is getting the enriched data out of the process. Three destinations cover most needs, and all three read the same object.

Writing a machine-readable line per failure is the simplest and survives CI log truncation:

Python
import json, pathlib, pytest

LOG = pathlib.Path("test-events.jsonl")

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    if report.when != "call":
        return
    LOG.open("a").write(json.dumps({
        "nodeid": report.nodeid,
        "outcome": report.outcome,             # passed | failed | skipped
        "duration": round(report.duration, 4), # per-phase seconds
        "markers": [m.name for m in item.iter_markers()],
    }) + "\n")

The terminal summary is the second destination: appending to report.sections puts your text in the failure block that pytest already prints, which is where a developer will actually look. And report.user_properties is the third — a list of key/value pairs that pytest writes into JUnit XML, making custom fields visible to CI dashboards without any parsing of the log.

Keep the wrapper cheap. It runs three times for every test in the suite, so a filesystem write per phase on a 20,000-test run is 60,000 writes; filter first, buffer if the volume is high, and never do network I/O inside it.

The fields worth reading on a TestReport A stack of five TestReport fields with what each contains: when for the phase, outcome for the result, longrepr for the failure representation, duration for the phase timing, and user_properties for custom key-value pairs that reach JUnit XML. The fields worth reading on a TestReport report.when setup, call or teardown — filter on this first report.outcome passed, failed or skipped for this phase report.longrepr the formatted failure; None when the phase passed report.duration seconds for this phase alone, not the whole test report.user_properties key/value pairs exported to JUnit XML item.stash is the supported place for cross-hook state on modern pytest.
These five cover almost every reporting plugin; anything richer usually belongs on the item rather than the report.

Ordering wrappers against other plugins

Once more than one plugin wraps the same hook, order matters, and pytest gives two levers for it. tryfirst=True and trylast=True position an implementation relative to others, and for wrappers the semantics are worth stating precisely: a tryfirst wrapper's pre-yield code runs earliest and its post-yield code runs latest, because wrappers nest rather than queue.

Python
import pytest

@pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_runtest_makereport(item, call):
    # trylast: this wrapper is innermost, so it writes its field before any
    # outer wrapper reads the report.
    outcome = yield
    report = outcome.get_result()
    report.user_properties.append(("suite", item.config.getoption("--suite", "default")))

The practical rule: if your wrapper reads a report other plugins enrich, be outermost (tryfirst); if it writes a field another plugin should see, be innermost (trylast). Getting this backwards produces a field that is present locally and missing in CI, because the plugin set differs between the two environments.

The second lever is registration order, which for conftest-based hooks follows the directory hierarchy — a root conftest registers before a nested one, and both register before the test module's own hooks. A project-wide wrapper in the root conftest is therefore naturally outermost relative to a directory-specific one, without any explicit ordering.

To see the resolved order rather than infer it, run with --trace-config, which prints every registered plugin and the hooks it implements at startup. On a suite with a dozen plugins this is the fastest way to answer "who else is touching this hook", and it is usually the first command to run when a wrapper works locally and does nothing in CI — where an extra plugin from the CI requirements file has quietly inserted itself into the chain.

Bash
$ pytest --trace-config -q 2>&1 | grep -A1 makereport
  plugin name: rerunfailures ... pytest_runtest_makereport
  plugin name: conftest ...     pytest_runtest_makereport (hookwrapper)

A final ordering trap: pytest_runtest_makereport wrappers see reports for every test, including ones another plugin has already marked as expected failures. Guard on the marks and the phase rather than assuming an item reached your code because it was interesting.

How two wrappers nest around one hook A left-to-right sequence showing nesting: the outer wrapper pre-yield code runs, then the inner wrapper pre-yield code, then the real implementations build the report, then the inner post-yield code, and finally the outer post-yield code. How two wrappers nest around one hook outer pre tryfirst wrapper inner pre trylast wrapper hooks run report is built inner post writes fields outer post reads all Read-only wrappers belong outermost; wrappers that add fields belong innermost.
Wrappers nest rather than queue: the first to enter is the last to see the finished report.

Edge cases and failure modes

  • Forgetting report.when filtering makes the body run three times per test, which triples the cost and produces duplicate log lines for every test.
  • Calling outcome.get_result() before the yield raises, because no implementation has run yet. The result only exists after control returns.
  • Raising inside the wrapper breaks the run. An exception after the yield propagates into pytest's internals and produces an INTERNALERROR, not a test failure. Wrap risky work in try/except and degrade quietly.
  • rep_call may not exist. When setup fails, the call phase never runs, so fixture teardown must use getattr(request.node, "rep_call", None) rather than attribute access.
  • Under pytest-xdist, wrappers run in the worker process. Anything written to a shared path needs the worker id in its name, exactly as described in pytest-xdist vs pytest-parallel.

One more consideration before shipping a wrapper to a shared codebase: decide what happens when the enrichment itself fails. A wrapper that raises turns every test in the suite into an internal error, so the safe shape is a narrow try/except around the body with a single warning emitted once per session. Failing quietly is the right trade here — a reporting plugin that degrades to standard pytest output is an inconvenience, while one that aborts the run is an outage.

For pytest 8 and later there is a newer wrapper form worth knowing, because new plugins should prefer it: @pytest.hookimpl(wrapper=True) uses a return value instead of outcome.get_result(), and exceptions propagate normally rather than being captured in the outcome object. The old hookwrapper=True form still works and remains the right choice for a plugin that must support pytest 7, but the modern form is shorter and its error handling is far less surprising.

Frequently Asked Questions

What is the difference between a hookwrapper and a normal hook implementation? A normal implementation contributes a result. A hookwrapper runs around every other implementation: code before the yield runs first, the yield hands control to the real implementations, and code after the yield sees their result and can inspect or modify it.

Why does my makereport hook run three times per test? Because pytest reports each phase separately: setup, call and teardown. Filter on report.when so the logic you want only runs for the phase you mean, usually call.

How do I make a fixture know whether the test passed? Stash the report on the item from the wrapper — item.stash or a plain attribute keyed by phase — then read it in the fixture's teardown after the yield.

← Back to Building Custom Pytest Plugins