Pytest Hooks Tutorial: 5 Practical Examples for Python Tests
Learn where pytest hooks fit, when to choose a hook over a fixture, and how to customize collection, reporting, and test runs with working examples.
Article summary
TL;DR
- A pytest hook is a framework callback that runs at a defined point in configuration, collection, test execution, or reporting.
- Use fixtures to supply test data and resources; use hooks to change or observe pytest itself.
- A local
conftest.pycan add CLI options, register markers, filter collected tests, inspect reports, and extend terminal output. - Use
@pytest.hookimpl(wrapper=True)when code must run around other implementations of the same hook. - Keep hook signatures small, test the plugin behavior, and account for other plugins and distributed workers before sharing the hook across projects.
01 / EXTENSION POINT
Pytest hooks let you change the test runner, not the test.
A growing pytest suite eventually needs behavior that does not belong inside an individual test. You may want a project-specific command-line option, automatic marker handling, a collection policy, or a compact failure report for CI.
Copying that logic into test functions makes every test harder to read. Hiding it in an autouse fixture can be just as confusing when the behavior actually belongs to the runner.
That is the job of pytest hooks.
A pytest hook is a function that pytest calls at a defined point in its lifecycle so a plugin can observe or change framework behavior.
This tutorial assumes you can already write and run ordinary pytest tests. If that is not true yet, start with the beginner tutorial for your first pytest test, then return when assert, discovery, and basic failures feel familiar.
We will build one local plugin with five practical hook functions:
pytest_addoptionto add--env;pytest_configureto register anenvmarker;pytest_collection_modifyitemsto skip tests for other environments;pytest_runtest_makereportto record failed test calls;pytest_terminal_summaryto print a triage list.
The result is small enough to understand in one sitting, but it exercises configuration, collection, execution, reporting, hook wrappers, and shared plugin state.
02 / LIFECYCLE
Pick a hook by the event you need to intercept.
Pytest implements much of its own behavior through plugins. A hook specification defines a function name, accepted arguments, when it runs, and what its result means. A hook implementation is your function for that specification.
Several plugins can implement the same hook. The call is usually one-to-many: pytest asks every registered implementation to participate, then combines their results according to the hook specification.
The five hooks in this tutorial sit at different points in a run:
startup
pytest_addoption
pytest_configure
collection
pytest_collection_modifyitems
each test
setup -> pytest_runtest_makereport(when="setup")
call -> pytest_runtest_makereport(when="call")
teardown -> pytest_runtest_makereport(when="teardown")
finish
pytest_terminal_summary
This is a working map, not a complete pytest hooks list. The official hook reference also includes bootstrapping, test generation, debugging, assertion, collection, runtest, and reporting hooks.
These are the hook families most application test suites reach for first:
| Hook | When to use |
|---|---|
pytest_addoption |
Add project-specific command-line or configuration options. |
pytest_configure |
Register markers or initialize plugin state after options are parsed. |
pytest_generate_tests |
Generate parameter sets during collection. |
pytest_collection_modifyitems |
Mark, reorder, skip, or deselect collected test items. |
pytest_runtest_setup, pytest_runtest_call, and pytest_runtest_teardown |
Run logic during setup, during the test call, or during teardown. |
pytest_runtest_makereport |
Inspect the report for a setup, call, or teardown phase. |
pytest_terminal_summary |
Add human-readable output at the end of a terminal run. |
pytest_sessionfinish |
React when the complete session finishes or adjust its exit status. |
Choose from the event you need, then read that hook’s exact specification. Similar names can expose different objects or run at different times.
Hook arguments are not ordinary dependency injection. Pytest validates their names against the specification when it registers the plugin. You can omit arguments you do not use, which is why this implementation is valid even though the full specification also provides session:
def pytest_collection_modifyitems(config, items):
...
Pytest passes only the named arguments the function requests. This dynamic argument pruning helps existing plugins remain compatible when a hook specification gains a new argument. A misspelled or invented argument fails plugin validation instead of quietly receiving None.
03 / HOOK OR FIXTURE
Use a fixture for test dependencies and a hook for runner behavior.
Hooks and fixtures can both run code around tests, but they solve different problems. The useful distinction is who needs the behavior.
| Requirement | Better tool | Why |
|---|---|---|
| Give a test a database record, client, clock, or temporary directory | Fixture | The dependency is part of the test scenario and can be named in its signature. |
| Create and clean up a resource for selected tests | Yield fixture | Setup and teardown stay attached to the tests that request the resource. |
| Add a CLI option or register project configuration | Hook | The behavior changes pytest before test functions run. |
| Mark, reorder, skip, or deselect collected tests by policy | Collection hook | The decision applies to pytest’s item collection. |
| Observe pass, fail, and skip reports | Reporting hook | The final outcome belongs to pytest’s execution and reporting lifecycle. |
| Generate cases from a CLI value | pytest_generate_tests hook |
Parametrization must be decided while tests are being collected. |
Do not add a fixture name to a hook signature. Hook parameters come from the hook specification, not from pytest’s fixture resolver. Although a late runtest hook can inspect state on an item, reaching into item.funcargs couples the plugin to test setup and is rarely a good way to request a dependency.
A practical rule is simple: if the test consumes a value, start with a fixture. If pytest itself must react to an event, look for a hook.
The same preference for visible dependencies applies beyond hooks. The Python unit testing best-practices guide explains when explicit fixtures help and when a local variable is clearer.
04 / DEMO PROJECT
Start with two tests and one environment marker.
The complete example is available in the istranin.dev code examples repository under pytest-hooks-examples/. It is a self-contained uv project that locks pytest 9.1.1 and targets Python 3.13.
pytest-hooks-examples/
├── checkout.py
├── conftest.py
├── failure_example.py
├── test_checkout.py
├── test_hooks.py
├── pyproject.toml
└── uv.lock
The application behavior is deliberately small:
# checkout.py
def checkout_message(environment: str) -> str:
"""Return the checkout status for an environment."""
return f"checkout ready in {environment}"
One test is valid everywhere. The other should run only when the suite selects the staging environment:
# test_checkout.py
import pytest
from checkout import checkout_message
def test_checkout_is_available_locally() -> None:
assert checkout_message("local") == "checkout ready in local"
@pytest.mark.env("staging")
def test_checkout_is_available_in_staging() -> None:
assert checkout_message("staging") == "checkout ready in staging"
There is no custom decorator or helper to call from the tests. The marker describes a property of the test. The local plugin in conftest.py will decide what that property means for collection.
05 / CONFIG AND COLLECTION
Combine startup and collection hooks into one useful policy.
Put the first three hook functions in the repository-level conftest.py:
import pytest
def pytest_addoption(parser: pytest.Parser) -> None:
"""Add the environment selector used by this test suite."""
parser.addoption(
"--env",
action="store",
default="local",
choices=("local", "staging"),
help="run tests for the selected environment",
)
def pytest_configure(config: pytest.Config) -> None:
"""Register the environment marker in pytest's active configuration."""
config.addinivalue_line(
"markers",
"env(name): run the test only for the named environment",
)
def pytest_collection_modifyitems(
config: pytest.Config,
items: list[pytest.Item],
) -> None:
"""Skip tests whose environment marker does not match ``--env``."""
selected_env = config.getoption("--env")
for item in items:
env_marker = item.get_closest_marker("env")
if env_marker is None:
continue
required_env = env_marker.args[0]
if required_env != selected_env:
item.add_marker(
pytest.mark.skip(reason=f"requires --env={required_env}")
)
pytest_addoption runs early enough to make --env part of pytest’s parser. The choices constraint rejects unsupported values before collection begins.
pytest_configure registers the marker description. This prevents unknown-marker warnings and makes pytest --markers explain the project convention.
pytest_collection_modifyitems runs after collection. It receives the item list, finds the closest env marker on each item, and adds a normal skip marker when the selected environment does not match. Unmarked tests are left alone.
Run only the demonstration tests with the default environment:
$ uv run pytest test_checkout.py -q
.s [100%]
1 passed, 1 skipped
Select staging and both tests run:
$ uv run pytest test_checkout.py -q --env=staging
.. [100%]
2 passed
This example skips nonmatching items so the report shows that they exist. If you remove items from the collection instead, report them through config.hook.pytest_deselected(items=deselected) so pytest and other plugins receive the correct deselection event.
Basic hook implementations do not need @pytest.hookimpl. Their pytest_* names are enough. The marker becomes useful when you need options such as wrapper, tryfirst, trylast, or specname.
06 / FAILURE REPORTS
Wrap report creation and keep shared state on the pytest config.
Now add the two reporting hooks. The first observes the report created for each test phase. The second adds the collected failures to the terminal summary.
from collections.abc import Generator
import pytest
FAILED_TESTS = pytest.StashKey[list[str]]()
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(
item: pytest.Item,
call: pytest.CallInfo[None],
) -> Generator[None, pytest.TestReport, pytest.TestReport]:
"""Remember failed test calls after other report hooks have run."""
report = yield
if report.when == "call" and report.failed:
failed_test_ids = item.config.stash.setdefault(FAILED_TESTS, [])
failed_test_ids.append(report.nodeid)
return report
def pytest_terminal_summary(
terminalreporter: pytest.TerminalReporter,
config: pytest.Config,
) -> None:
"""Add a compact failure list to the end of terminal output."""
failed_test_ids = config.stash.get(FAILED_TESTS, [])
if not failed_test_ids:
return
terminalreporter.section("failed tests for triage")
for nodeid in failed_test_ids:
terminalreporter.write_line(nodeid)
pytest_runtest_makereport is called after setup, call, and teardown. Filtering on report.when == "call" means the list contains failed test bodies, not fixture setup or teardown errors. Whether that is correct depends on your reporting requirement; a CI artifact collector may want all three phases.
The wrapper yields exactly once. Code before yield runs before later hook implementations. The yielded expression receives the report after the wrapped implementations finish, so this plugin can inspect the outcome without constructing a report itself. Returning the report preserves the result for the rest of the call chain.
pytest.StashKey gives the plugin a private typed key on pytest’s config object. It avoids a custom attribute name that may collide with another plugin. The state still belongs to one pytest process. A plugin that must aggregate failures from pytest-xdist workers needs an explicit worker-to-controller data path rather than assuming one shared stash.
The package includes failure_example.py, which pytest ignores during normal discovery. Run it explicitly to see the extra section:
$ uv run pytest failure_example.py -q
...
=========================== failed tests for triage ============================
failure_example.py::test_inventory_count
...
1 failed
The command exits with status 1. Custom reporting should never turn a real failure into a successful process.
07 / ORDER AND SAFETY
Treat every hook as part of a larger plugin chain.
The difficult part of pytest hooks is not memorizing names. It is remembering that your implementation can run beside built-in plugins, third-party plugins, and other conftest.py files.
For a single hook call, the useful ordering model is:
- hook wrappers run until
yield; - regular implementations marked
tryfirst=Truerun early; - unmarked regular implementations run;
- regular implementations marked
trylast=Truerun late; - wrappers resume after
yieldin reverse nesting order.
tryfirst and trylast influence order within the relevant group. They do not make one plugin the permanent owner of a hook. Avoid designs that depend on an unspecified order between two unmarked implementations.
Some hook specifications use firstresult=True. For those hooks, pytest stops after the first implementation returns a non-None value. That behavior belongs to the specification, not to the implementation. Read the reference before returning a value from a hook that can replace pytest behavior.
There are several other constraints worth keeping close:
- Pytest validates hook argument names when the plugin is registered.
- Hook functions other than
pytest_runtest_*should not raise exceptions, because an exception can break the entire run. - A
conftest.pyhook is visible only within that file’s directory scope and descendants. - Some very early startup hooks cannot be implemented in a later-loaded
conftest.py. - Current pytest documentation uses
wrapper=True; older examples may show the olderhookwrapper=Trueprotocol with different result handling.
Start in a root conftest.py when the behavior is local to one test suite. Move it into an installable plugin when several repositories need it, when configuration grows, or when compatibility with other plugins becomes a product requirement. The official plugin guide covers discovery and packaging.
Plugin behavior deserves tests too. The companion package uses pytest’s pytester plugin to run a deliberately failing test in isolation and assert that the custom triage section appears. This protects the user-visible report instead of unit-testing the generator one line at a time.
Only declare a new hook specification with pytest_addhooks when other plugins genuinely need to extend your plugin. Most application test suites need to implement existing pytest hooks, not invent another plugin API.
08 / NEXT STEP
Add the smallest hook that removes a real test-suite problem.
Before adding a hook, walk through this sequence:
- Name the pytest event you need to observe or change.
- Check whether an explicit fixture, marker, or configuration file solves the problem more clearly.
- Read the hook specification, including its arguments, return behavior, and timing.
- Implement only the arguments you use in a local
conftest.py. - Keep frequent hooks free of unnecessary network, filesystem, or database work.
- Test the visible outcome through a real pytest run.
- Exercise the plugin beside the third-party plugins and execution modes your CI actually uses.
The best first hook is usually not a custom collector or a new plugin framework. It is a small response to a repeated problem: one missing command option, one noisy collection rule, or one report detail that developers keep reconstructing by hand.
Continue from pytest runner customization to fixtures, parametrization, mocking, coverage, and CI in the complete Pytest course.
View courseImplement that one hook, run the suite, and make its effect obvious. If the behavior later becomes useful outside the repository, the tested local plugin gives you a clean starting point for extracting it.
More field notes
Keep reading.
Python Unit Testing Best Practices: 10 pytest Rules That Scale
Write Python unit tests that survive refactoring. Use pytest to protect behavior, choose useful cases, control dependencies, and keep the suite fast.
Python unit testingpytestGitHub Actions Python Testing: A Complete pytest CI Guide
Build a pytest workflow in GitHub Actions with dependency caching, multiple Python versions, branch coverage, reports, and a stable merge check.
pytestGitHub ActionsTest-Driven Development in Python with pytest: A Practical Guide
Use pytest to practice red-green-refactor on a realistic inventory rule. Write each failing test first, add only enough code, and refactor safely.
Test-driven developmentpytest