Chapter 16 · Custom Plugins and Hook Development
Chapter goals: understand pytest's pluggy-based hook system, master the four most-used hooks, and build a working local plugin that combines "custom command-line options + collection filtering".
16.1 The Hook Mechanism: pytest's Skeleton
pytest's own configuration, collection, execution, and reporting are entirely implemented by invoking a set of standardized hook functions. The underlying library is pluggy, with three core semantics:
- 1:N invocation: one hook specification can have N implementations. All registered implementations run in LIFO order (last registered runs first);
- Argument-name pruning: a hook implementation only declares the arguments it cares about; pluggy injects them by name. This is the secret of pytest's long-term compatibility — new spec arguments never break old plugins;
- firstresult and wrappers: some hooks are marked
firstresult=True(stop at the first non-None result); any hook can also be written as a "wrapper" inserting logic before/after.
import pytest
# Ordinary hook implementation: declare only the parameters you need
def pytest_collection_modifyitems(config, items):
# Called after collection completes; you may mutate the items list in place
...
# firstresult semantics: once one returns non-None, remaining implementations are skipped
# Hook wrapper: a generator function that yields exactly once
@pytest.hookimpl(wrapper=True)
def pytest_pyfunc_call(pyfuncitem):
print("Before the test function executes")
result = yield # The actual test runs here
print("After the test function executes")
return result # Return (or rewrite) the result; exceptions also surface at the yieldImportant constraint
Except for the pytest_runtest_* family (the test execution phase), other hooks must not raise exceptions — doing so aborts the whole pytest process.
16.2 The Four Most-Used Hooks
pytest_addoption: command-line options / ini settings
def pytest_addoption(parser):
group = parser.getgroup("myproj") # Group name shown in --help
group.addoption(
"--run-slow",
action="store_true",
default=False,
help="Also run tests marked slow",
)
parser.addini("api_base", help="Service under test URL", default="http://localhost:8000")Note: pytest_addoption must live in the root conftest.py or an installed plugin, because it must take effect before the command line is parsed.
pytest_configure: read configuration, initialize globals
def pytest_configure(config):
# Command line and ini are fully parsed by now — safe to read options
config._api_base = config.getoption("--run-slow")pytest_collection_modifyitems: filter/reorder after collection
def pytest_collection_modifyitems(config, items):
if config.getoption("--run-slow"):
return # No filtering
skip_slow = pytest.mark.skip(reason="--run-slow not given")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow) # Add skip in placepytest_runtest_makereport: capture per-case results
This is a firstresult hook sitting on the execution path; the standard pattern wraps it to grab results:
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
rep = yield # The real report object is produced here
if rep.when == "call":
# rep.outcome ∈ {"passed", "failed", "skipped"}
print(f"{item.nodeid} -> {rep.outcome}")
return repThis pattern underpins plugins like pytest-rerunfailures, failure screenshots, and Allure reporting.
16.3 Building a Complete Small Plugin
Assemble the pieces into a local plugin: skip slow cases by default unless --run-slow is passed, then report counts at the end:
# conftest.py -- local plugin example
import pytest
def pytest_addoption(parser):
group = parser.getgroup("tutorial")
group.addoption("--run-slow", action="store_true", default=False,
help="Include cases marked @pytest.mark.slow")
def pytest_configure(config):
config.addinivalue_line("markers", "slow: slower integration tests")
if not config.getoption("--run-slow"):
config._skip_slow = True
else:
config._skip_slow = False
def pytest_collection_modifyitems(config, items):
if not getattr(config, "_skip_slow", False):
return
skip = pytest.mark.skip(reason="--run-slow not specified")
kept = []
for item in items:
if "slow" in item.keywords:
item.add_marker(skip)
kept.append(item)
def pytest_terminal_summary(terminalreporter):
stats = terminalreporter.stats
skipped = len(stats.get("skipped", []))
terminalreporter.write_sep("=", f"Skipped {skipped} slow case(s)")
# In test file test_api.py:
# import pytest
#
# @pytest.mark.slow
# def test_full_export():
# ...$ pytest -m "not slow" # Marker approach (Chapter 4)
$ pytest # Plugin approach: slow skipped automatically, no expression to remember
$ pytest --run-slow # Turn it on explicitlyInstead of making everyone memorize -m "not slow", a plugin bakes the team convention into the tool itself — that is exactly the value of writing plugins.
16.4 conftest.py Is a Local Plugin
The official docs are explicit: conftest.py is a directory-scoped local plugin. Its loading rules are worth memorizing:
- At startup, all conftests along the way load in order "command-line paths → parent directories up to rootdir";
- Fixtures/hooks from conftests closer to the test file win (LIFO);
- A conftest may itself declare
pytest_plugins = ["mycompany.plugin"]to load additional module-level plugins.
For a plugin meant to be installed in any project, register an entry point in your package metadata (pyproject.toml example):
[project.entry-points.pytest11]
myplugin = "myplugin.plugin"pytest11 is the name group pytest reserves for plugins; installing the package lets pytest discover it automatically. During development you can also load it temporarily via pytest -p myplugin.plugin.
Chapter Summary
- Hooks ARE how pytest works: 1:N invocation + argument-name pruning + firstresult/wrapper;
addoption → configure → collection_modifyitems → runtest_makereportcovers 80% of plugin needs;- Non-runtest hooks must not raise; wrappers use
@pytest.hookimpl(wrapper=True)+ exactly one yield; - conftest.py is a local plugin following "root to leaf, closer wins"; publishing relies on the
pytest11entry point.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Where must custom options registered by pytest_addoption live first?
2. What is the correct way to write a hook wrapper?
3. To move cases whose names contain smoke to the end after collection, which hook fits best?
4. After pip-installing a third-party plugin, how does pytest discover it?
🛠️ Hands-on Practice
- Add a
--fail-fastoption to the 16.3 plugin: stop the whole session on the first failure (hint: studypytest_runtest_protocol, or exit from within the makereport wrapper using means beyondpytest.UsageError). - Write a hook wrapper measuring each case's duration and printing the Top 5 slowest (use
call.start/call.stopor time it yourself). - Extract your plugin into its own package with a pytest11 entry point registered in pyproject.toml;
pip install -eit in a fresh project and verify auto-activation.
Your toolbelt is fully loaded. The next chapter tackles testing asynchronous code.