Pytest & CI

Building Custom Pytest Plugins

Building Custom Pytest Plugins

Transforming pytest from a generic test runner into a domain-specific testing framework requires a deep understanding of its extension architecture. By leveraging pluggy hooks, entry point discovery, and AST-based assertion rewriting, engineering teams can build reusable, production-grade plugins that standardize test behavior across monorepos and open-source ecosystems. This guide details the architectural patterns, lifecycle mechanics, and distribution strategies required for robust pytest plugin development.

Understanding Pytest's Plugin Architecture

At its core, pytest relies on pluggy, a lightweight plugin framework that decouples hook specifications (hookspec) from their implementations (hookimpl). When pytest initializes, it constructs a plugin manager that registers all discovered plugins, resolves hook chains, and executes them in a deterministic order. Understanding the Advanced Pytest Architecture & Configuration foundation is critical before extending the runner, as misaligned hook implementations can silently break collection or execution phases.

The plugin lifecycle follows a strict sequence:

  1. Initialization (pytest_configure): Plugins parse configuration, register custom markers, and set up global state.
  2. Collection (pytest_collect_file, pytest_pycollect_makeitem): Test modules are discovered, parsed, and converted into Item and Collector objects.
  3. Setup & Execution (pytest_runtest_setup, pytest_runtest_call): Fixtures are resolved, tests execute, and teardown occurs.
  4. Reporting (pytest_terminal_summary, pytest_runtest_logreport): Results are aggregated, formatted, and emitted to stdout/files.

A minimal production-ready plugin structure separates core logic from packaging metadata:

Plain text
pytest-myplugin/
├── src/
│ └── pytest_myplugin/
│ ├── __init__.py
│ ├── plugin.py # Hook implementations
│ └── fixtures.py # Reusable fixture definitions
├── tests/
│ └── test_integration.py
└── pyproject.toml

The plugin.py module typically houses hookimpl decorators. Crucially, pluggy resolves hooks by name and executes them in reverse registration order by default, unless tryfirst=True or trylast=True is specified. This deterministic resolution allows plugins to safely wrap or intercept core pytest behavior without monkeypatching internal modules.

Registering and Discovering Custom Plugins

Plugin discovery hinges on Python packaging entry points. When pytest boots, it iterates through installed distributions and scans for the pytest11 namespace. Any package exposing an entry point under this namespace is automatically imported and registered with the plugin manager.

Configure discovery in pyproject.toml using PEP 621 standards:

TOML
[project.entry-points.pytest11]
myplugin = "pytest_myplugin.plugin"

This declarative registration ensures the plugin activates across any project where the package is installed, eliminating the need for manual conftest.py imports. However, precedence rules dictate behavior: installed plugins load before local conftest.py files, but conftest.py can override plugin fixtures and hooks within its directory tree. Use pytest --trace-config to audit the exact load order and verify registration:

Bash
$ pytest --trace-config -q
PLUGIN registered: pytest_myplugin.plugin (from: /path/to/site-packages)
PLUGIN registered: _pytest.main (from: /path/to/site-packages/_pytest/main.py)
...

Engineering Trade-off: While conftest.py offers rapid iteration during development, it lacks version control and cross-project portability. Distributed plugins should always be preferred for shared infrastructure, as they enable semantic versioning, dependency pinning, and CI matrix validation.

Integrating with Fixtures and Parametrization

Plugins frequently expose domain-specific fixtures that encapsulate complex setup logic. When defining fixtures within a plugin, scope management and autouse behavior require careful consideration to avoid unintended side effects or resource contention. Cross-reference fixture scoping with Mastering Pytest Fixtures and dynamic test generation with Advanced Parametrization Techniques when discussing plugin-driven test injection.

A plugin-defined session-scoped fixture for database connection pooling:

Python
import pytest

@pytest.fixture(scope="session", autouse=True)
def db_pool(request):
 config = request.config.getoption("--db-url")
 pool = create_connection_pool(config)
 yield pool
 pool.close_all()

For dynamic test generation, the pytest_generate_tests hook intercepts collection and injects parameter sets before execution. This is particularly valuable for data-driven testing or matrix validation without modifying test signatures:

Python
def pytest_generate_tests(metafunc):
 if "env_config" in metafunc.fixturenames:
 # Load from plugin config or external matrix
 envs = metafunc.config.getoption("--env-matrix", default=["staging", "prod"])
 metafunc.parametrize("env_config", envs, scope="function")

Parallel Execution Considerations: When using pytest-xdist, session-scoped fixtures execute once per worker process. Plugins must implement thread-safe state management or use pytest-xdist's worker_id fixture to isolate resources. Global mutable state in plugins will cause race conditions and flaky failures in distributed runs.

Implementing Custom Hooks and Reporting

Hook execution order dictates how plugins interact with pytest's internal state. The following table outlines critical reporting and execution hooks:

HookPhasePurposeExecution Guarantee
pytest_runtest_protocolExecutionWraps setup/call/teardownCalled once per test item
pytest_runtest_makereportReportingModifies TestReport objectsInvoked after each phase
pytest_terminal_summaryPost-runAppends to CLI outputExecuted after all tests

Detail terminal output customization by linking to Implementing custom pytest hooks for reporting when explaining result aggregation.

A practical pytest_terminal_summary implementation for emitting custom metrics:

Python
def pytest_terminal_summary(terminalreporter, exitstatus, config):
 terminalreporter.section("Custom Plugin Metrics", sep="=")
 passed = len(terminalreporter.stats.get("passed", []))
 failed = len(terminalreporter.stats.get("failed", []))
 terminalreporter.write_line(f"Total Passed: {passed}")
 terminalreporter.write_line(f"Total Failed: {failed}")

For granular execution control, pytest_runtest_protocol allows plugins to intercept the entire test lifecycle. However, overriding this hook requires explicit delegation to item.runtest() and proper exception handling to avoid breaking pytest's internal teardown pipeline:

Python
import pytest

@pytest.hookimpl(tryfirst=True)
def pytest_runtest_protocol(item, nextitem):
 item.config.pluginmanager.get_plugin("capture").suspendcapture()
 try:
 # Custom pre-execution logic
 yield
 finally:
 item.config.pluginmanager.get_plugin("capture").resumecapture()

Pitfall: Failing to call item.runtest() or swallowing exceptions in pytest_runtest_protocol silently breaks test isolation and corrupts the runner's internal state machine. Always wrap custom logic in try/finally and delegate execution explicitly.

Assertion Rewriting and Custom Validation

Pytest's assertion rewriting is a compile-time AST transformation that enhances standard assert statements with rich introspection. When a plugin requires custom validation logic, it must register its modules for rewriting before they are imported. Explain bytecode interception and introspection by referencing Building custom pytest assertion plugins during the AST transformation walkthrough.

Registration occurs during pytest_configure:

Python
def pytest_configure(config):
 import pytest
 pytest.register_assert_rewrite("pytest_myplugin.assertions")

The pytest_myplugin/assertions.py module can then define helpers that leverage pytest's internal assertion rewriting:

Python
def assert_json_schema_match(response, schema):
 """Custom assertion with detailed diff output."""
 errors = validate_schema(response, schema)
 assert not errors, f"Schema validation failed:\n{format_errors(errors)}"

Under the hood, pytest replaces assert expr with assert expr, "expr" and injects pytest_assertion_pass/pytest_assertion_fail hooks. When rewriting plugin modules, ensure the import hook is registered before any test imports the module. Otherwise, Python's standard import machinery will cache unrewritten bytecode in __pycache__, resulting in opaque assertion failures.

Debugging Tip: Run pytest --assert=plain to disable rewriting temporarily and verify baseline behavior. Use PYTHONVERBOSE=1 to trace import hooks and confirm that pytest._rewrite intercepts the target module.

Real-World Plugin Case Study: Network Call Tracing

Architecting a VCR-style plugin requires intercepting HTTP clients at the transport layer, caching responses, and replaying them deterministically. Demonstrate practical HTTP interception by linking to Tracing network calls with pytest-recording when covering session fixtures and external dependency mocking.

The plugin initializes a session-scoped recorder in pytest_configure:

Python
import pytest
import requests
from unittest.mock import patch

def pytest_configure(config):
 config.pluginmanager.register(NetworkTracerPlugin(config))

class NetworkTracerPlugin:
 def __init__(self, config):
 self.config = config
 self.cassette_path = config.getoption("--record-mode", default="replay")

 @pytest.hookimpl(tryfirst=True)
 def pytest_sessionstart(self, session):
 self._monkeypatch_requests()

 def _monkeypatch_requests(self):
 original_send = requests.Session.send
 def patched_send(self, request, *args, **kwargs):
 key = f"{request.method}:{request.url}"
 if self.cassette_path == "record":
 resp = original_send(self, request, *args, **kwargs)
 save_cassette(key, resp)
 return resp
 return load_cassette(key)
 requests.Session.send = patched_send

This approach avoids modifying test code while providing deterministic network behavior. For CI environments, the plugin should default to replay mode and fail fast if a cassette is missing. Thread safety is maintained by isolating cassette I/O per worker process and using file locking for concurrent writes.

Trade-off Analysis: Monkeypatching at the requests.Session level intercepts all downstream libraries (e.g., httpx, aiohttp if wrapped). However, it bypasses lower-level socket mocking. For strict isolation, consider intercepting at the urllib3 connection pool level instead.

Packaging, Testing, and Distribution

Production plugins require rigorous integration testing before publication. The pytester fixture, provided by pytest-dev, creates isolated temporary environments, writes test files programmatically, and executes pytest subprocesses to validate output and exit codes.

A tox.ini configuration for multi-environment validation:

INI
[tox]
envlist = py39, py310, py311, py312, lint

[testenv]
deps = pytest>=7.0
commands = pytest tests/ -v

[testenv:lint]
deps = ruff, mypy
commands = ruff check src/ tests/
 mypy src/

Integration test using pytester:

Python
def test_plugin_registers_hook(pytester):
 pytester.makeconftest("""
 import pytest
 def pytest_configure(config):
 config.pluginmanager.register(MyPlugin())
 """)
 result = pytester.runpytest("--help")
 result.stdout.fnmatch_lines(["*--myplugin-option*"])
 assert result.ret == 0

Publishing follows standard PyPI workflows: python -m build generates source and wheel distributions, while twine upload dist/* publishes them. Enforce semantic versioning and pin pytest compatibility in project.dependencies. Always test against the latest pytest minor release in CI to catch breaking changes in pluggy or internal APIs before users encounter them.

Frequently Asked Questions

How do I test a custom pytest plugin without installing it globally? Use the pytester fixture provided by pytest-dev. It creates an isolated temporary environment, writes test files, and runs pytest programmatically to assert expected output and exit codes.

What is the difference between conftest.py and a distributed plugin?conftest.py is local to a directory tree and auto-discovered, while distributed plugins are registered via entry points and activated across projects. Plugins are preferred for reusable, versioned extensions.

Can a custom plugin modify test parametrization dynamically? Yes, via the pytest_generate_tests hook. It intercepts collection and injects parameter sets before execution, enabling data-driven testing without modifying test functions directly.

How does pytest handle assertion rewriting in plugins? Plugins register modules for rewriting using pytest.register_assert_rewrite(). pytest intercepts import hooks, compiles AST with enhanced failure introspection, and caches bytecode for subsequent runs.