Pytest Testing Framework: Architecture, Hooks, and Plugin System Explained

Pytest is a feature-rich, extensible Python testing framework built on a hook-based plugin architecture that enables automatic test discovery, dependency injection via fixtures, and flexible reporting through the pluggy library.

The pytest-dev/pytest repository provides a mature testing infrastructure that goes beyond basic assertion checking. Its architecture is designed around three core concepts—collection and discovery, hooks and plugins, and execution and reporting—that together enable everything from simple unit tests to complex integration scenarios.

Collection and Discovery

Pytest automatically locates and collects test modules, classes, and functions using the pytest_collect protocol implemented in src/_pytest/pathlib.py and src/_pytest/python.py. The framework walks the filesystem to identify files matching the patterns test_*.py or *_test.py.

Once identified, test objects are wrapped in Item subclasses defined in src/_pytest/python.py, including Function, Instance, and Class types. This collection tree forms the structure that the execution engine traverses during test runs.

Hooks and Plugins

The heart of pytest's extensibility lies in its hook system, defined using the pluggy library. Pytest registers a comprehensive set of hooks in src/_pytest/hookspec.py that plugins can implement to modify behavior.

Key hooks include:

  • pytest_runtest_setup – Executed before each test runs
  • pytest_collection_modifyitems – Allows modification of collected items before execution
  • pytest_configure – Called once after command-line options are parsed

Core functionality for fixtures, markers, and command-line options is provided by built-in plugins located under src/_pytest. Third-party plugins such as pytest-cov and pytest-mock are discovered via entry points defined in pyproject.toml or setup.cfg.

Creating a Custom Plugin

To extend pytest, create a plugin module implementing hook specifications:


# my_plugin.py

import pytest

def pytest_configure(config):
    # Called once after command line options are parsed

    config.addinivalue_line(
        "markers", "slow: mark test as slow-running"
    )

@pytest.hookimpl
def pytest_collection_modifyitems(session, config, items):
    for item in items:
        if "slow" in item.keywords and config.getoption("--run-slow"):
            item.add_marker(pytest.mark.run_slow)

Activate the plugin via entry point:


# pyproject.toml

[project.entry-points.pytest11]
my_plugin = "my_plugin"

Execution and Reporting

Test execution is orchestrated by the Node API. A Session object creates a Collector tree, then iterates over Item objects. Each Item runs its runtest method, invoking fixtures defined in src/_pytest/fixtures.py and yielding a CallInfo result.

The assertion rewrite mechanism in src/_pytest/assertion/rewrite.py intercepts import statements to rewrite assert statements, providing detailed failure messages without requiring special assertion methods.

Reporting is handled by reporter classes:

  • Terminal reporting: src/_pytest/terminal.py formats and outputs results to the console
  • JUnit XML: src/_pytest/junitxml.py generates JUnit-compatible XML reports for CI integration
  • Custom formats: Additional reporters can be implemented via hooks

Key Architectural Components

Component Purpose Important Files
Path & Collection Resolves paths, builds collection tree src/_pytest/pathlib.py, src/_pytest/python.py
Hook System Enables plugins to extend behavior src/_pytest/hookspec.py, src/_pytest/hooks.py
Fixtures Manages dependency injection for tests src/_pytest/fixtures.py
Assertion Rewrite Improves failure messages by rewriting assert statements src/_pytest/assertion/rewrite.py
CLI & Config Parses command-line options, reads pytest.ini, pyproject.toml src/_pytest/config/__init__.py, src/_pytest/main.py
Reporting Generates terminal, JUnit XML, HTML reports src/_pytest/terminal.py, src/_pytest/junitxml.py

Practical Code Examples

Simple Test with Fixtures


# tests/test_math.py

import pytest

@pytest.fixture
def numbers():
    return (1, 2, 3)

def test_sum(numbers):
    a, b, c = numbers
    assert a + b + c == 6

Using Markers and Command-Line Options


# tests/test_api.py

import pytest

@pytest.mark.slow
def test_long_running():
    # simulate a long operation

    assert True

Run only fast tests:

pytest -m "not slow"

Generate JUnit XML reports:

pytest --junitxml=results.xml

Custom Timing Plugin


# plugin/timer.py

import time
import pytest

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
    start = time.time()
    yield
    duration = time.time() - start
    print(f"{item.name} took {duration:.3f}s")

Enable via:

pytest -p timer

Summary

Frequently Asked Questions

How does pytest discover test files automatically?

Pytest implements a filesystem walker in src/_pytest/pathlib.py that identifies modules matching the test_*.py and *_test.py naming conventions. Once identified, src/_pytest/python.py imports these modules and wraps test functions and classes into Item subclasses like Function and Class, building a collection tree that the execution engine traverses.

What is the role of fixtures in pytest's architecture?

Fixtures serve as the dependency injection mechanism managed by src/_pytest/fixtures.py. They allow tests to request resources—such as database connections, temporary directories, or mock objects—through function arguments. The fixture system handles setup, caching, and teardown automatically, enabling modular test design and resource sharing across multiple test cases without explicit boilerplate code.

How can I extend pytest with custom plugins?

You can extend pytest by implementing hook specifications defined in src/_pytest/hookspec.py using the pluggy library. Create a Python module with functions decorated with @pytest.hookimpl, such as pytest_collection_modifyitems to alter test selection or pytest_configure to add command-line options. Register your plugin via entry points in pyproject.toml under [project.entry-points.pytest11] or activate it temporarily using the -p command-line flag.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →