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 newerwrapper=Truestyle with areturninstead 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.
# 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:
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()
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.
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:
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.
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.
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.
$ 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.
Edge cases and failure modes
- Forgetting
report.whenfiltering 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 intry/exceptand degrade quietly. rep_callmay not exist. When setup fails, the call phase never runs, so fixture teardown must usegetattr(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.
Related
- Building custom pytest plugins — packaging, registration and the rest of the hook surface.
- Packaging a pytest plugin with entry points — shipping the wrapper as an installable plugin.
- Debugging flaky tests with pytest-rerunfailures — the same hook, used to record every rerun attempt.
- Pytest configuration best practices — where a project-wide wrapper belongs in the configuration hierarchy.
← Back to Building Custom Pytest Plugins