Pytest & CI

Advanced Pytest Architecture & Configuration

Modern Python testing has moved far past the rigid class-based inheritance model of unittest. Pytest's dominance in enterprise and open-source ecosystems stems from its modular architecture, declarative fixture system, and extensible hook pipeline. For mid-to-senior engineers, QA architects, and maintainers, understanding pytest's internal mechanics is not optional—it is the prerequisite for scaling a suite past a few thousand tests without it degrading into a slow, flaky bottleneck that engineers learn to distrust and route around.

This guide dissects pytest's execution pipeline, its configuration resolution algorithm, the directed acyclic graph (DAG) that the fixture dependency resolver walks to order setup and teardown, and the pluggy hook dispatch system that every plugin plugs into. It assumes proficiency with Python OOP, decorators, context managers, AST manipulation, and modern packaging (PEP 621). The objective is architectural: to let you design test infrastructure that stays deterministic under parallelism, boots quickly in CI, and degrades predictably when something breaks.

Pytest execution pipeline Three sequential phases — collection, setup and teardown, then execution — all mediated by the pluggy hook dispatch bus. The pytest execution pipeline Collection AST scan & node tree Setup / Teardown fixture DAG resolution Execution assert rewriting pluggy hook dispatch bus pytest_collection_modifyitems pytest_runtest_protocol · pytest_configure
Every phase of a pytest run is mediated by pluggy: collection builds the node tree, the fixture DAG resolver orders setup and teardown, and execution runs the assertion-rewritten test body.

1. Pytest Execution Pipeline & Internal Architecture

Pytest operates on a strictly phased execution model, orchestrated by the pluggy plugin framework. The lifecycle distills into three primary phases: Collection, Setup/Teardown, and Execution. Beneath that abstraction sits a node-traversal and hook-dispatch system that every serious customization eventually reaches into.

The Node Hierarchy & Collection Phase

Pytest represents every testable entity as a pytest.Node subclass. The hierarchy flows downward: SessionPackageModuleClassFunction (the concrete Item). During collection, pytest imports each discovered module and walks its AST, instantiating Module nodes and then scanning for Class and Function definitions. Each Item carries the metadata that later phases depend on: its applied markers, its parametrization tuples, and the names of the fixtures it requests. Nothing runs yet—collection only builds the tree.

Assertion Rewriting Mechanics

Unlike plain Python, pytest transforms assertions at import time. The AssertionRewritingHook intercepts module imports, parses the AST, and rewrites each assert statement into an introspectable form that captures the repr() of both operands. That is why assert order.total == 42 yields a detailed left-vs-right diff instead of a bare AssertionError, with no self.assertEqual() ceremony. The rewritten bytecode is cached in __pycache__; when the cache directory is writable the rewrite happens once and costs nothing on subsequent runs. Test helper modules imported before pytest registers the hook are not rewritten—wrap them with pytest.register_assert_rewrite("mypkg.helpers") in your top-level conftest.py so shared assertion helpers still produce rich diffs.

Hook Dispatch & pluggy Integration

The core of pytest's extensibility is pluggy, a minimalistic plugin manager. Every phase is mediated by hookspec declarations (the contract) and hookimpl implementations (your code). Tracing two hooks shows how plugins intercept and modify flow:

Python
# conftest.py
import pytest


@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(session, config, items):
    """Sort tests by a custom 'priority' marker before execution."""
    def priority(item):
        marker = item.get_closest_marker("priority")
        return marker.args[0] if marker else 99

    items.sort(key=priority)


@pytest.hookimpl(wrapper=True)  # pytest >=7.2 / pluggy >=1.2 form
def pytest_runtest_protocol(item, nextitem):
    """Wrap each test to observe its outcome without owning the run."""
    start = __import__("time").perf_counter()
    result = yield  # the wrapped hooks run here; `result` is their return
    elapsed = __import__("time").perf_counter() - start
    if elapsed > 1.0:
        print(f"[TRACE] {item.nodeid} took {elapsed:.2f}s")
    return result

The wrapper=True form (which superseded the older hookwrapper=True + outcome.get_result() dance in pytest 7.2) lets a plugin run logic before and after the wrapped hooks and return their result, all without monkey-patching or touching global state. Why this matters in CI/CD: because hooks compose deterministically, you can layer timing, tracing, and quarantine logic on top of a suite you do not own, and trust that ordering is governed by tryfirst/trylast/wrapper rather than import accidents. That determinism is what keeps a hook-heavy conftest.py from behaving differently on a developer laptop and a 32-way CI runner.

2. Configuration Management & Resolution Order

Configuration resolution in pytest follows a strict precedence hierarchy, and misreading it is one of the most common causes of "passes locally, fails in CI." The configuration best-practices playbook covers validation and environment overrides in depth; the mechanics below are what everything there is built on.

Precedence Algorithm

From highest precedence to lowest, pytest resolves options in this order:

  1. Command-line flags (pytest -v --tb=short) — highest authority.
  2. PYTEST_ADDOPTS — injected as if prepended to the command line, ideal for CI-only overrides.
  3. The first ini file found (pyproject.tomlpytest.initox.inisetup.cfg) — pytest stops at the first match and does not merge across files.
  4. Built-in defaults compiled into pytest core.

The single most important consequence: pytest picks exactly one configuration file. If a pyproject.toml in the repo root defines [tool.pytest.ini_options], a stray pytest.ini two directories down that you thought was active is silently ignored, because rootdir discovery locked onto the first file it found. Run pytest --co -q and read the rootdir: and configfile: lines it prints before debugging any "my settings aren't applying" report.

Dynamic Configuration & Custom Options

pytest_addoption registers ini keys and CLI flags; pytest_configure runs after the config file is parsed but before collection, which makes it the right place to validate options and register markers programmatically:

TOML
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--strict-markers --tb=short --durations=10"
markers = [
    "integration: tests requiring external service connectivity",
    "slow: long-running performance benchmarks",
]
Python
# conftest.py
import pytest


def pytest_addoption(parser):
    parser.addini(
        "integration_timeout",
        "Timeout for integration tests, in seconds",
        default="30",
        type="string",
    )


def pytest_configure(config):
    timeout = int(config.getini("integration_timeout"))
    if timeout > 60:
        config.issue_config_time_warning(
            pytest.PytestConfigWarning(f"High integration_timeout: {timeout}s"),
            stacklevel=2,
        )

Why this matters in CI/CD: --strict-markers turns a typo'd @pytest.mark.integraiton into a hard collection error instead of a silently-skipped test, and PYTEST_ADDOPTS="-p no:cacheprovider --dist=loadscope" lets you tune a CI run without editing a version-controlled file. Standardizing these keys is what makes conditional selection—covered in markers for conditional test execution—reliable across every pipeline stage.

3. Test Collection & Discovery Pipeline

Collection is frequently the primary bottleneck in large monorepos: before a single assertion runs, pytest has imported hundreds of modules and walked their ASTs. Optimizing test discovery is a topic in its own right; the fundamentals below determine how much work the collector does at all.

AST Traversal & Pattern Optimization

By default pytest collects files matching test_*.py or *_test.py, then classes prefixed Test and functions prefixed test_. The expensive part is not the regex—it is the filesystem walk and the imports. Two levers dominate:

  • norecursedirs prunes whole subtrees before they are ever stat-ed. Add vendor, node_modules, .venv, build, and generated-code directories aggressively.
  • testpaths tells pytest where to start when invoked with no arguments, so a bare pytest in a monorepo does not scan the entire tree.

Import time also counts: a conftest.py that imports a heavy framework at module scope pays that cost during collection, on every worker, before any test runs.

Runtime Filtering & Sorting

The pytest_collection_modifyitems hook is the single point where you filter, reorder, or annotate items after the tree is built:

Python
# conftest.py
import re
import pytest

VENDOR_PATTERN = re.compile(r"vendor|third_party|external_libs")


@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(config, items):
    """Drop vendored trees and run fast tests first for quicker CI feedback."""
    kept = [it for it in items if not VENDOR_PATTERN.search(str(it.path))]

    def duration_hint(item):
        marker = item.get_closest_marker("slow")
        return 1 if marker else 0  # slow tests sort last

    items[:] = sorted(kept, key=duration_hint)

Collection caching (the .pytest_cache directory) stores lastfailed and stepwise state, powering --lf and --sf. Why this matters in CI/CD: ordering fast tests first surfaces failures in seconds instead of minutes, and a --collect-only job that fails on unexpected node-count drift catches an entire test directory that silently stopped being discovered—one of the quietest and most dangerous regressions a suite can have. When discovery cost pushes you toward parallelism, the pytest-xdist vs pytest-parallel comparison quantifies the trade-offs.

4. Fixture Dependency Graph & Scope Resolution

Pytest's fixture system is, mechanically, a directed acyclic graph resolver. When a test requests a fixture, FixtureManager assembles the transitive dependency tree, checks scope compatibility, and topologically sorts it to determine setup order—teardown then runs in the exact reverse. The full lifecycle is the subject of mastering pytest fixtures; what follows is the resolution model every advanced pattern rests on.

Fixture dependency graph for test_query A three-node chain — db_engine at session scope, transaction at module scope, and the test_query function — with setup running top to bottom and teardown running bottom to top in reverse topological order. Fixture DAG for test_query db_engine scope=session · built once for the run transaction scope=module · once per module test_query scope=function · the test item Setup build widest scope first Teardown reverse order — narrowest first
The resolver topologically sorts the graph to set up the widest scope first; teardown then runs in the exact reverse, so transaction is torn down before db_engine even though both use yield.

Scope Inheritance & DAG Traversal

Scopes define lifecycle boundaries, widest to narrowest: session > package > module > class > function. A wider-scoped fixture may not depend on a narrower one—doing so raises ScopeMismatch, because a session fixture built once cannot legally consume a function fixture rebuilt for every test. The resolver instantiates each fixture exactly once per its scope, caches the value, and hands the same object to every dependent within that scope.

Yield-Based Teardown & Exception Guarantees

Yield fixtures give deterministic cleanup even when a test fails. The teardown half runs in reverse topological order: the last fixture set up is the first torn down. Exception handling is precise—if a fixture raises during setup, its dependents are errored (not run); if teardown raises, pytest records the error but continues so one leaked resource does not cascade into a wall of unrelated failures.

Python
import pytest


@pytest.fixture(scope="session")
def db_engine():
    engine = {"conn": "mock_connection", "pool": []}
    yield engine
    # runs once, after the entire session — reverse order guarantees this is last
    engine["pool"].clear()


@pytest.fixture(scope="module")
def transaction(db_engine):
    db_engine["pool"].append("tx_1")
    yield db_engine
    db_engine["pool"].pop()  # torn down before db_engine, per reverse topo order


def test_query(transaction):
    assert transaction["pool"] == ["tx_1"]

Why this matters in CI/CD: a widely-shared session fixture that never releases its handles is exactly the shape of leak that surfaces as an OOM kill on a memory-constrained runner and nowhere else. Confirming the DAG with pytest --setup-show and hunting retained objects with memory profiling via tracemalloc turns those intermittent CI failures into reproducible ones. Async fixtures add loop-lifetime constraints on top of scope—scoping fixtures for async tests and the pytest-asyncio vs anyio scoping trade-offs cover where those two interact.

5. Conftest Hierarchies & Namespace Isolation

conftest.py files are not ordinary imported modules; they are configuration scripts pytest loads automatically during collection. Their scope is directory-bound, which is what makes hierarchical isolation possible—and what makes accidental cross-contamination so easy. Managing conftest hierarchies is the deep treatment; the loading rules below are the contract.

conftest.py resolution order Three stacked conftest files from rootdir down to tests/integration/auth, with lookup walking upward and the deepest definition of api_client overriding the parent it extends. conftest.py resolution — nearest definition wins conftest.py — rootdir shared: db_engine, settings tests/integration/conftest.py api_client → api.staging.internal tests/integration/auth/conftest.py api_client(api_client): + auth_token Every conftest on the path loads; deepest wins Lookup walks up to rootdir
A test under auth/ sees the nearest api_client; because that definition requests the parent fixture of the same name, the override extends the staging client rather than silently shadowing it.

Loading & Inheritance Rules

Pytest walks upward from each test file's directory to the rootdir, loading every conftest.py on the path. Fixtures defined in a deeper directory override same-named fixtures higher up—the nearest-conftest-wins rule—so a package can specialize a fixture for its own tests without touching anyone else's.

Avoiding Fixture Shadowing & Pollution

Shadowing occurs when a child conftest.py defines a fixture with the same name as a parent. Intentional overriding is a feature; accidental shadowing is a bug that produces non-deterministic behavior depending on which directory a test lives in. The clean pattern is to override by extending—request the parent fixture of the same name and augment it:

Python
# tests/integration/conftest.py
import pytest


@pytest.fixture
def api_client():
    return {"base_url": "https://api.staging.internal"}


# tests/integration/auth/conftest.py
import pytest


@pytest.fixture
def api_client(api_client):  # requests the parent, then extends it
    api_client["auth_token"] = "bearer_mock_123"
    return api_client

Why this matters in CI/CD: in a monorepo, a conftest.py that mutates sys.path or performs import side effects at module scope changes behavior for every sibling package that happens to sit below it, producing failures that only reproduce when a particular set of paths is collected together. The safe patterns for large repositories are laid out in creating conftest.py hierarchies for monorepos.

6. Dynamic Test Generation & Parametrization

Static @pytest.mark.parametrize is enough for fixed input tables, but data-driven suites at scale need tests generated at collection time from external sources. pytest_generate_tests is the mechanism; the full range of matrix and indirect patterns lives in advanced parametrization techniques.

Runtime Hook & Indirect Resolution

pytest_generate_tests fires once per test function during collection and receives a metafunc object. Calling metafunc.parametrize(..., indirect=True) routes each parameter into a fixture rather than straight into the test argument, which defers expensive setup until the parametrized fixture actually runs:

Python
import json
from pathlib import Path
import pytest


def pytest_generate_tests(metafunc):
    if "matrix_config" in metafunc.fixturenames:
        data_path = Path(__file__).parent / "test_data" / "matrix.json"
        configs = json.loads(data_path.read_text()) if data_path.exists() else []
        # ids= keeps failures readable: test_matrix[postgres-utf8] not [config2]
        metafunc.parametrize(
            "matrix_config",
            configs,
            ids=[c["name"] for c in configs],
            indirect=True,
        )


@pytest.fixture
def matrix_config(request):
    config = dict(request.param)  # copy so cases never share mutable state
    config["initialized"] = True
    return config


def test_matrix_execution(matrix_config):
    assert matrix_config["initialized"] is True

Why this matters in CI/CD: generating cases from a data file means adding a new scenario is a data change, not a code change, and readable ids= turn a red build into a diagnosable one—test_matrix[redis-latin1] tells you the failing combination at a glance. When the input space is too large to enumerate by hand, hand it off to property-based testing with Hypothesis, which generates and shrinks boundary inputs automatically instead of requiring you to curate them.

7. Plugin Architecture & Hook Extension

Pytest's entire plugin ecosystem is pluggy all the way down. A plugin is simply a module that registers hookimpl functions and exposes them through an entry point. Turning your repo's conftest.py conventions into a distributable, versioned package is the subject of building custom pytest plugins.

Hookspec vs Hookimpl Validation

A hookspec defines the contract—the parameter names a hook may receive and whether it is firstresult. A hookimpl provides your logic and may request any subset of the spec's parameters by name. pluggy validates argument names at registration time and raises PluginValidationError on a mismatch, which catches a renamed-parameter typo the moment the plugin loads rather than mid-run. Ordering is controlled by tryfirst, trylast, and wrapper.

Entry Points & Distribution

Modern plugins declare themselves under [project.entry-points.pytest11], so pytest discovers them automatically once the package is installed—no pytest_plugins string, and none of the eager-import side effects that string carries:

TOML
# pyproject.toml
[project.entry-points.pytest11]
my_custom_plugin = "my_plugin.core"
Python
# my_plugin/core.py
import pytest


@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
    report = yield  # the real report object from pytest's own implementation
    if report.when == "call" and report.failed:
        report.user_properties.append(("owner", item.get_closest_marker("owner")))
    return report

Cross-plugin state belongs in config.stash (a typed, collision-free store) rather than module globals, which is what keeps two independently-authored plugins from silently clobbering each other's data. Why this matters in CI/CD: packaging your suite's conventions as a versioned plugin means every service in the org pins the same test behavior through its lockfile, and an intentional change ships as a plugin release with a changelog instead of a silent edit to a shared conftest.py that no one reviews.

8. Performance Profiling & Enterprise Scaling

Scaling pytest to thousands of tests is an architecture problem, not just a -n auto flag. pytest-xdist distributes work across worker processes, but process isolation changes the semantics of shared state in ways that quietly break suites written for sequential execution.

pytest-xdist distribution modes Three rows comparing how load, loadscope, and loadfile assign the same six tests — two classes in test_api.py and a test_db.py file — across two worker processes, and how grouping changes fixture reuse. pytest-xdist distribution modes — tests mapped to workers Worker 1 Worker 2 --dist=load round-robin fixtures rebuilt on both a1 u1 d1 a2 u2 d2 --dist=loadscope group by module / class class fixture once per group TestAuth a1 a2 TestUsers u1 u2 test_db d1 d2 --dist=loadfile group by file per-file fixture once test_api.py a1 a2 u1 u2 test_db.py d1 d2 Two classes live in test_api.py (a·, u·); test_db.py holds d·. Grouping keeps a shared fixture on one worker.
Under load, colour-coded tests scatter and every per-class or per-file fixture is rebuilt on both workers; loadscope keeps each class together, and loadfile keeps the whole file on one worker so its fixture is built exactly once there.

Distribution Strategies & Worker Isolation

  • --dist=load — round-robin. Best for independent, fast unit tests.
  • --dist=loadscope — groups tests by module or class, so a shared module/class-scoped fixture is set up once per worker instead of thrashing.
  • --dist=loadfile — groups by file. Optimal for integration tests that share a heavy per-file fixture.
  • --dist=no — sequential baseline for reproducing an ordering-sensitive failure.

Session-scoped fixtures are not shared across workers: each worker is a separate OS process running its own session, so a session fixture is built once per worker. Genuinely global state (a schema migration, a shared container) must be coordinated—use the worker_id fixture plus a file lock so exactly one worker performs the setup:

Python
# conftest.py
import pytest
from filelock import FileLock  # pip install filelock


@pytest.fixture(scope="session")
def shared_schema(tmp_path_factory, worker_id):
    if worker_id == "master":  # running without xdist
        return _create_schema()

    # One worker creates the schema; the rest wait on the lock and reuse it.
    root = tmp_path_factory.getbasetemp().parent
    marker = root / "schema.ready"
    with FileLock(str(root / "schema.lock")):
        if not marker.exists():
            _create_schema()
            marker.write_text("ok")
    return _connect_schema()


def _create_schema():
    return {"tables": ["users", "orders"]}


def _connect_schema():
    return {"tables": ["users", "orders"]}

Profiling & CI Optimization

Surface the slowest tests with --durations=20, trace hot paths with pytest-profiling, and split collection cost from execution cost with a timing wrapper hook. Long-lived session fixtures that accumulate objects are the classic source of CI OOM kills; pair --durations with tracemalloc snapshot comparison to attribute the growth to a specific fixture. Why this matters in CI/CD: sharding by historical duration keeps every worker finishing at roughly the same wall-clock time, so total pipeline latency is bounded by the slowest shard rather than the slowest test. Flaky tests undermine all of it—quarantine them explicitly with pytest-rerunfailures rather than letting random red builds erode trust in the suite.

Common Pitfalls & Antipatterns

  1. Overusing autouse fixtures. Root cause: invisible setup runs for every test, and its teardown order is hard to reason about. Fix: reserve autouse for cross-cutting concerns (logging, env validation) and make everything else an explicit dependency so the DAG is readable in pytest --setup-show.
  2. ScopeMismatch from a wide fixture requesting a narrow one. Root cause: a session fixture depends on a function fixture, which cannot exist for the session's lifetime. Fix: widen the dependency's scope, or invert the design so the narrow fixture consumes the wide one.
  3. Mutating sys.path inside conftest.py. Root cause: it bypasses package resolution and breaks editable installs and import isolation. Fix: use pip install -e . and the src/ layout; let import machinery, not path hacks, resolve packages.
  4. Assuming sequential execution under xdist. Root cause: a plugin or fixture relies on global order or a shared in-process object. Fix: coordinate through worker_id + a file lock or an external service; never a module global.
  5. Relying on a pytest.ini that pytest ignored. Root cause: a higher-precedence pyproject.toml won config-file discovery. Fix: read the configfile: line from pytest --co -q and consolidate on one file.
  6. Session-scoped state leaking across workers. Root cause: expecting a single global instance from an inherently per-process scope. Fix: make the fixture idempotent and guard the one-time setup with a lock, as in Section 8.
  7. String-based plugin registration. Root cause: pytest_plugins = ["my_plugin"] triggers eager imports and namespace collisions. Fix: register via [project.entry-points.pytest11] so discovery is install-time and explicit.

Frequently Asked Questions

How does pytest resolve conflicting fixtures across multiple conftest.py files? Pytest uses a nearest-conftest-wins algorithm. During collection it builds a FixtureManager registry by traversing upward from the test file's directory, and a fixture defined in a deeper directory overrides parent definitions of the same name. Collisions are resolved by directory depth, not import order, so the result is deterministic. Inspect the final registry with pytest --fixtures (add -v to see the defining file for each).

Can I dynamically register hooks at runtime without writing a standalone plugin? Yes—inside pytest_configure you can call config.pluginmanager.register(module) to inject hookimpl functions into the manager. The limitation is timing: hooks registered after pytest_collection_modifyitems has already fired cannot change collection order, and re-registering the same object raises ValueError. For anything that must run every build, prefer static entry_points registration so discovery is deterministic.

Why does pytest-xdist sometimes skip or duplicate session-scoped fixtures? Worker processes are OS-isolated, so each worker runs its own pytest session and instantiates session-scoped fixtures independently—there is no shared session across workers. Use --dist=loadfile to keep tests that share a fixture on the same worker, or gate one-time global setup behind a worker_id check plus a file lock and share the result through an external service (Redis, a database, a container).

How do I profile and optimize a 5000+ test suite without modifying test code? Instrument through hooks, not monkey-patching. Use --durations=20 to surface the slowest tests and pytest-profiling for macro tracing, restrict python_files and prune norecursedirs to cut collection cost, and distribute with pytest-xdist --dist=loadscope. At the CI level, shard by historical duration so worker wall-clock times converge, and fail a --collect-only job on unexpected node-count drift to catch silently-undiscovered directories.

← Back to all guides