DEV Community

Peyton Green
Peyton Green

Posted on

pytest plugins that actually change how you test in 2026

Every pytest tutorial covers the same five built-ins. --v, -k, -x, --tb, capsys. You already know these.

These aren't those.

Seven plugins that permanently changed my test setup — each one solves a real problem I didn't know I had until I hit it at scale.


1. pytest-httpx — HTTP mocking that doesn't require a VCR setup

The classic solution for mocking HTTP calls was responses or requests-mock. Both work, but they require you to pre-record responses or manually construct them. If your code makes three HTTP calls, you write three response fixtures.

pytest-httpx takes a different approach: it intercepts httpx calls at the transport layer and lets you assert on what was sent and control what comes back.

# pip install pytest-httpx
import httpx
import pytest
from pytest_httpx import HTTPXMock

def fetch_user(user_id: int) -> dict:
    response = httpx.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

def test_fetch_user(httpx_mock: HTTPXMock):
    httpx_mock.add_response(
        url="https://api.example.com/users/42",
        json={"id": 42, "name": "Alice", "role": "admin"},
    )

    user = fetch_user(42)

    assert user["name"] == "Alice"
    # pytest-httpx will FAIL the test if the registered mock wasn't called
    # No silent miss — you know if your code made the wrong call
Enter fullscreen mode Exit fullscreen mode

The key behavior: if you register a mock and your code doesn't call it, the test fails. This surfaces routing bugs that responses lets slip through silently.

For async code, the same fixture works — no separate anyio setup needed:

async def test_fetch_user_async(httpx_mock: HTTPXMock):
    httpx_mock.add_response(json={"id": 42, "name": "Alice"})
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/users/42")
    assert response.json()["name"] == "Alice"
Enter fullscreen mode Exit fullscreen mode

Why it matters: The shift from requests to httpx in modern Python (especially FastAPI and async automation scripts) means responses is losing relevance. pytest-httpx is the right mock for the new stack.


2. pytest-randomly — catch order dependencies before they catch you

Tests should be independent. In practice, they often aren't — shared state, module-level singletons, database connections that aren't properly torn down. The bug manifests as "test passes locally, fails in CI" or "test suite used to work, broke two weeks ago after we added test_foo.py."

pip install pytest-randomly
Enter fullscreen mode Exit fullscreen mode

No configuration needed. Install it, run your test suite. It randomizes test execution order on every run and prints the seed used:

pytest --randomly-seed=12345
Using --randomly-seed=12345
Enter fullscreen mode Exit fullscreen mode

When a test suddenly fails after installation, you've found an order dependency. The seed output lets you reproduce the failure reliably:

pytest --randomly-seed=last  # re-run with same seed that just failed
Enter fullscreen mode Exit fullscreen mode

The most common bug it surfaces: tests that share a module-level fixture or a class attribute that gets mutated.

# This will fail non-deterministically without pytest-randomly surfacing it
class TestUserWorkflow:
    users = []  # ← mutable class attribute

    def test_create_user(self):
        self.users.append({"id": 1, "name": "Alice"})
        assert len(self.users) == 1

    def test_list_users(self):
        assert len(self.users) == 0  # ← fails if test_create_user ran first
Enter fullscreen mode Exit fullscreen mode

Why it matters: Order-dependent tests are silent tech debt. They pass in CI (deterministic order) until someone reorders a file. pytest-randomly makes the non-determinism visible before it becomes an incident.


3. anyio's built-in pytest plugin — async tests without the boilerplate

If you're testing async code (FastAPI handlers, async automation pipelines, LLM clients), pytest-asyncio works but requires a lot of decorator management. anyio ships its own pytest plugin (there was briefly a separate pytest-anyio package on PyPI — it's now a 0.0.0 placeholder whose own description says "built into anyio, you don't need this package"), and it's the cleaner evolution:

# pip install anyio[trio]
import anyio
import pytest

# Mark the whole module as async
pytestmark = pytest.mark.anyio

async def fetch_items(client: anyio.abc.ByteStream) -> list[str]:
    # async implementation
    ...

async def test_fetch_items():
    # No @pytest.mark.asyncio on every test
    # anyio runs the event loop automatically
    result = await fetch_items(mock_stream)
    assert result == ["item1", "item2"]
Enter fullscreen mode Exit fullscreen mode

The key advantage: anyio supports both asyncio and trio backends. You can run your test suite against both backends by overriding the anyio_backend fixture with a parametrized one:

import pytest

@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request):
    return request.param
Enter fullscreen mode Exit fullscreen mode

This catches code that accidentally uses asyncio-specific internals. If you're building libraries or automation tools that might run under either backend, this is the difference between "compatible" and "we think it's compatible."

Fixture integration is clean:

@pytest.fixture
async def database_connection():
    async with create_async_engine(TEST_DSN).connect() as conn:
        yield conn  # proper async teardown — no contextvar leaks

async def test_user_query(database_connection):
    result = await database_connection.execute(select(User).limit(1))
    assert result.scalar_one_or_none() is not None
Enter fullscreen mode Exit fullscreen mode

Why it matters: The async testing landscape is fragmented. anyio's backend-agnostic approach is the right long-term bet, especially as the Python async ecosystem converges on anyio-compatible patterns.


4. pytest-snapshot — golden file testing without the friction

Some outputs are too complex to assert field-by-field: serialized data structures, API response shapes, rendered templates, generated SQL queries. The alternative is "snapshot testing" — assert that output matches a saved reference file, with a flag to update the reference.

# pip install syrupy  (pytest-snapshot is the concept, syrupy is the modern implementation)
from syrupy import SnapshotAssertion

def serialize_user_report(users: list[dict]) -> dict:
    return {
        "total": len(users),
        "by_role": {
            role: sum(1 for u in users if u["role"] == role)
            for role in set(u["role"] for u in users)
        },
        "sample": users[:3],
    }

def test_user_report_shape(snapshot: SnapshotAssertion):
    users = [
        {"id": 1, "name": "Alice", "role": "admin"},
        {"id": 2, "name": "Bob", "role": "member"},
        {"id": 3, "name": "Carol", "role": "admin"},
    ]
    report = serialize_user_report(users)
    assert report == snapshot  # first run: creates snapshot file
    # subsequent runs: asserts against the saved snapshot
Enter fullscreen mode Exit fullscreen mode

First run creates __snapshots__/test_reports.ambr:

# serializer version: 1
# name: test_user_report_shape
  {
    'by_role': {
      'admin': 2,
      'member': 1,
    },
    'sample': [...],
    'total': 3,
  }
Enter fullscreen mode Exit fullscreen mode

When the output changes intentionally, update with --snapshot-update. The diff in your PR shows exactly what changed in the serialization format — invaluable when refactoring data models.

Why it matters: Field-by-field assertions on complex structures are maintenance overhead. Snapshot tests make "does the shape change?" a first-class assertion with PR-visible diffs.


5. pytest-watch — sub-second feedback on file save

The standard pytest workflow: edit file, switch to terminal, hit up-arrow, enter. If your suite takes 3 seconds to run, that's 3+ seconds of context-switching per change.

pip install pytest-watch
ptw -- -x --tb=short  # pass additional pytest flags after --
Enter fullscreen mode Exit fullscreen mode

ptw watches your source files for changes and re-runs the relevant tests automatically. The -- -x --tb=short flags: stop on first failure, short traceback. This combination gives you near-instant feedback during active development.

# With a specific subset while working on one module
ptw tests/test_user_service.py -- -x
Enter fullscreen mode Exit fullscreen mode

Why it matters: The feedback loop is the bottleneck in test-driven development. pytest-watch removes the friction of re-running manually, which means you actually run tests continuously instead of in batches.


6. freezegun — time-dependent tests that don't lie

Date and time logic is infamously hard to test. The naive approach: compare against datetime.now() in your test with a tolerance. The problem: the test becomes non-deterministic and breaks when daylight saving time shifts.

There's a small pytest-freezegun wrapper that adds a @pytest.mark.freeze_time marker, but it depends on distutils, which was removed from the standard library in Python 3.12 — it fails to import on any current interpreter. Use freezegun's own @freeze_time decorator directly; it needs no pytest integration at all.

# pip install freezegun
from datetime import datetime
from freezegun import freeze_time

def get_subscription_status(start_date: datetime, duration_days: int) -> str:
    days_elapsed = (datetime.now() - start_date).days
    if days_elapsed >= duration_days:
        return "expired"
    return "active"

@freeze_time("2026-03-24 10:00:00")
def test_subscription_expiry():
    start = datetime(2026, 3, 1)  # 23 days ago (frozen)
    assert get_subscription_status(start, 30) == "active"
    assert get_subscription_status(start, 20) == "expired"

@freeze_time("2026-04-24 10:00:00")
def test_subscription_expiry_after_period():
    start = datetime(2026, 3, 1)  # 54 days ago (frozen)
    assert get_subscription_status(start, 30) == "expired"
Enter fullscreen mode Exit fullscreen mode

Frozen time works across datetime.now(), datetime.utcnow(), time.time(), and time.localtime(). No mocking needed, no manual patching.

Why it matters: Time-dependent code is a significant portion of automation scripts (scheduled tasks, subscription logic, rate limiting, audit logs). Testing it without freezing time means your test is only correct on the day you wrote it.


7. pytest-cov — coverage that actually tells you something

You probably already know about pytest-cov. But there's a configuration pattern most tutorials don't cover that makes coverage reports genuinely useful instead of just a number:

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing:skip-covered --cov-fail-under=80"

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
    "@overload",
]
Enter fullscreen mode Exit fullscreen mode

The key additions:

  • term-missing:skip-covered — only shows files with uncovered lines, skips fully-covered files. Output is signal, not noise.
  • --cov-fail-under=80 — fails the build below 80%. Prevents coverage creep.
  • exclude_lines — excludes type-checking blocks and protocol-only code from the count. These aren't runtime paths; counting them as uncovered is misleading.
pytest --cov=src --cov-branch  # --cov-branch catches conditional branches, not just lines
Enter fullscreen mode Exit fullscreen mode

Branch coverage catches if not x: where x was never True in your tests — the hidden uncovered path in your logic.

Why it matters: Raw line coverage rewards writing tests for simple code and ignores your branching logic. Branch coverage + the right exclusions tells you where your tests actually have gaps.


Putting it together

A full plugin setup that costs nothing and changes everything:

pip install pytest-httpx pytest-randomly anyio[trio] syrupy pytest-watch freezegun pytest-cov
Enter fullscreen mode Exit fullscreen mode
# pyproject.toml
[tool.pytest.ini_options]
addopts = [
    "--cov=src",
    "--cov-report=term-missing:skip-covered",
    "--cov-branch",
    "--cov-fail-under=80",
    "-x",              # stop on first failure
]
anyio_mode = "auto"  # anyio: no per-test marks needed

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
    "@overload",
]
Enter fullscreen mode Exit fullscreen mode

During active development, replace pytest with ptw (pytest-watch) for continuous feedback. In CI, pytest runs the full suite with coverage enforcement.

The test infrastructure this builds:

  • HTTP calls: mocked at the transport layer, failures surface immediately
  • Async code: single framework, both backends
  • Time logic: frozen to specific dates
  • Complex output: snapshot-tested with PR-visible diffs
  • Order dependencies: randomized execution finds them before CI does
  • Coverage: branch-level with the noise removed

What this series has covered

This is Part 4 of Testing Without the Subscription Tax:

  • Part 1 (March 19): LocalStack Now Requires an Account — moto and Floci as zero-subscription AWS mocking
  • Part 2 (April 7): pytest fixtures that actually scale — session scope, factory pattern, teardown
  • Part 3 (April 14): Stop writing edge case tests — Hypothesis for property-based testing
  • Part 4 (August 4): pytest plugins that actually change how you test

The complete testing setup costs $0/month. No subscriptions, no auth tokens, no vendor lock-in — just Python tooling that you own.


The scripts that use these patterns across real automation workflows are in the Python Automation Cookbook — 25 production-ready scripts with full test coverage using this exact setup.

Top comments (0)