Skip to content

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.
python
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 yield

Important 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 ​

python
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 ​

python
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 ​

python
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 place

pytest_runtest_makereport: capture per-case results ​

This is a firstresult hook sitting on the execution path; the standard pattern wraps it to grab results:

python
@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 rep

This 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:

python
# 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():
#     ...
bash
$ pytest -m "not slow"        # Marker approach (Chapter 4)
$ pytest                      # Plugin approach: slow skipped automatically, no expression to remember
$ pytest --run-slow           # Turn it on explicitly

Instead 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:

  1. At startup, all conftests along the way load in order "command-line paths → parent directories up to rootdir";
  2. Fixtures/hooks from conftests closer to the test file win (LIFO);
  3. 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):

toml
[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_makereport covers 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 pytest11 entry 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 ​

  1. Add a --fail-fast option to the 16.3 plugin: stop the whole session on the first failure (hint: study pytest_runtest_protocol, or exit from within the makereport wrapper using means beyond pytest.UsageError).
  2. Write a hook wrapper measuring each case's duration and printing the Top 5 slowest (use call.start/call.stop or time it yourself).
  3. Extract your plugin into its own package with a pytest11 entry point registered in pyproject.toml; pip install -e it in a fresh project and verify auto-activation.

Your toolbelt is fully loaded. The next chapter tackles testing asynchronous code.