python-pytest-patterns

Use this when authoring or reviewing any test under tests/, designing a new fixture and choosing its scope, wiring async tests via pytest-asyncio, registering custom markers, or replacing a unittest.T

SKILL.md

GitHub

python-pytest-patterns (v1.0.0)

Rationale

unittest.TestCase is acceptable Python but the Python pack default is pytest. The default is not stylistic: pytest's fixture model (parameter injection, scoped finalization, indirect parametrization) makes it harder to leak setup state between tests, makes test discovery uniform, and integrates with pytest-asyncio, pytest-benchmark, pytest-randomly, pytest-repeat, and the testcontainers-python ecosystem the testing-phase agents depend on. Every Python pack agent that runs tests assumes pytest semantics — sdk-integration-flake-hunter-python runs pytest --count=3, sdk-benchmark-devil-python reads pytest-benchmark JSON, sdk-asyncio-leak-hunter-python ships custom fixtures.

This skill is cited by code-reviewer-python (test quality), sdk-existing-api-analyzer-python (snapshot baseline parsing), sdk-integration-flake-hunter-python, sdk-benchmark-devil-python, sdk-complexity-devil-python, sdk-asyncio-leak-hunter-python, documentation-agent-python (doctest mode), and sdk-convention-devil-python (C-14).

Activation signals

  • Designing or reviewing any test file under tests/.
  • Writing a new pytest fixture; deciding scope (function / module / session).
  • Setting up integration tests that use testcontainers.
  • Adding async tests that need asyncio_mode.
  • Code review surfaces unittest.TestCase usage.
  • Code review surfaces a for-loop in a test instead of @pytest.mark.parametrize.

Core rules

Rule 1 — Pytest, not unittest

Tests live under tests/. Files named test_<module>.py. Functions named test_<behavior>. Classes named Test<Subject> (no __init__).

# tests/test_client.py
def test_client_publish_succeeds(): ...
def test_client_publish_fails_on_invalid_topic(): ...

class TestClient:
    def test_construction(self): ...
    def test_close_is_idempotent(self): ...

Avoid unittest.TestCase subclasses. They prevent fixture injection, cannot use @pytest.mark.parametrize, and require self.assertEqual instead of plain assert. If you inherit a TestCase from a third-party (e.g., TransactionTestCase from Django), wrap it in pytest.

Rule 2 — Parametrize over for-loops

# WRONG — for-loop in a test
def test_validators():
    cases = [("foo", True), ("", False), ("a" * 256, False)]
    for input_, expected in cases:
        assert is_valid(input_) is expected
        # First failure stops the loop; you don't see the others.

# RIGHT — parametrize
@pytest.mark.parametrize(
    "input_,expected",
    [
        pytest.param("foo", True, id="happy-path"),
        pytest.param("", False, id="empty"),
        pytest.param("a" * 256, False, id="too-long"),
    ],
)
def test_is_valid(input_: str, expected: bool) -> None:
    assert is_valid(input_) is expected

pytest.param(..., id="...") produces deterministic test IDs (test_is_valid[empty]) which downstream tools (sdk-integration-flake-hunter-python, CI dashboards) cite. Never use pytest.param(..., id=str(input_)) — special characters break test selection.

Rule 3 — Fixture scope is the most-frequently-mis-set knob

# Function scope (default) — recreated for every test. Use for:
@pytest.fixture
def fresh_config() -> Config:
    return Config(timeout=1.0)

# Module scope — created once per test module. Use for expensive but read-only setup.
@pytest.fixture(scope="module")
def shared_db_schema() -> Schema:
    return Schema.from_file("tests/data/schema.json")

# Session scope — created once per pytest invocation. Use for testcontainers.
@pytest.fixture(scope="session")
def postgres_container() -> Iterator[PostgresContainer]:
    container = PostgresContainer("postgres:16")
    container.start()
    yield container
    container.stop()

The risk in non-default scopes: state leaks across tests in the same module/session. ANY fixture with mutable state needs scope="function" unless you can guarantee no test mutates it. Common bug: a session-scoped Client whose internal cache silently contaminates the next test's behavior.

Rule 4 — yield for fixtures with cleanup

@pytest.fixture
def temp_log_dir(tmp_path: Path) -> Iterator[Path]:
    log_dir = tmp_path / "logs"
    log_dir.mkdir()
    yield log_dir
    # cleanup runs after the test, including on failure
    shutil.rmtree(log_dir, ignore_errors=True)

Do NOT use request.addfinalizer(...) for new fixtures — it predates yield and is now a legacy pattern. The yield form is cleaner and harder to forget.

tmp_path (per-test) and tmp_path_factory (broader scope) are pytest built-ins; never write to a hardcoded /tmp/foo path — concurrent test runners collide.

Rule 5 — conftest.py placement

Per-directory shared fixtures live in tests/<dir>/conftest.py. Top-level fixtures shared across all tests live in tests/conftest.py. Avoid placing conftest.py at repo root unless you specifically need fixtures available to non-test code (rare).

tests/
  conftest.py              # cross-cutting fixtures (Config defaults, etc.)
  unit/
    conftest.py            # unit-only fixtures (in-memory fakes)
    test_client.py
  integration/
    conftest.py            # testcontainer fixtures (session-scoped)
    test_client_e2e.py

Rule 6 — Async tests via pytest-asyncio in auto mode

Configure once in pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"

Then every async def test_... runs without per-test decoration:

async def test_client_publish_async(client: Client) -> None:
    await client.publish("topic", b"hi")
    assert client.last_publish.acked

Avoid asyncio_mode = "strict" for SDK code unless mixing sync and async tests in the same project — strict requires @pytest.mark.asyncio on every async test, which is noise.

Async fixtures use async def + yield:

@pytest.fixture
async def client(config: Config) -> AsyncIterator[Client]:
    async with Client(config) as c:
        yield c

Rule 7 — Custom markers REGISTER in pyproject.toml

[tool.pytest.ini_options]
markers = [
    "integration: requires running infrastructure (testcontainers, docker)",
    "slow: takes > 5 seconds",
    "leak_hunt: targeted by sdk-asyncio-leak-hunter-python",
    "perf: targeted by sdk-benchmark-devil-python",
]

Without registration, @pytest.mark.integration works but emits a PytestUnknownMarkWarning that taints CI logs. Registered markers also make pytest --markers list them for documentation.

Run subsets:

pytest -m "not integration"   # unit tests only
pytest -m "integration"       # integration only
pytest -m "leak_hunt"         # only leak-hunter targets

Rule 8 — monkeypatch for environment / attribute isolation

def test_reads_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("MOTADATA_API_KEY", "test-key")
    cfg = Config.from_env()
    assert cfg.api_key == "test-key"
    # auto-reverted after the test, even on assertion failure.

Use monkeypatch.setattr to replace a function/attribute for the duration of one test (e.g., stub a slow network call). Do NOT mutate os.environ directly — your test will leak state into the next test.

Rule 9 — caplog for structured log assertions

def test_publish_emits_warning(client: Client, caplog: pytest.LogCaptureFixture) -> None:
    caplog.set_level(logging.WARNING, logger="motadatapysdk.client")
    client.publish_with_retry("topic", b"x")
    warning_records = [r for r in caplog.records if r.levelname == "WARNING"]
    assert len(warning_records) == 1
    assert "retry" in warning_records[0].message

caplog.records returns the LogRecord objects (typed access to .levelname, .name, .message, .args). caplog.text is the rendered output (string match only — fragile).

Rule 10 — capfd / capsys for stdout/stderr

def test_cli_help(capsys: pytest.CaptureFixture[str]) -> None:
    main(["--help"])
    out = capsys.readouterr().out
    assert "Usage: motadata" in out

capfd captures at the file-descriptor level (catches subprocess output); capsys captures Python's sys.stdout/sys.stderr. Pick capfd when testing CLIs that may shell out.

Rule 11 — Fixture parametrization (indirect)

When the same test should run against multiple variants of a fixture:

@pytest.fixture
def client_with_backend(request: pytest.FixtureRequest) -> Client:
    backend_url = request.param
    return Client(Config(backend_url=backend_url))


@pytest.mark.parametrize(
    "client_with_backend",
    ["http://localhost:8080", "http://localhost:8081"],
    indirect=True,
)
def test_publish(client_with_backend: Client) -> None:
    client_with_backend.publish("topic", b"x")

indirect=True means the parametrize value is fed INTO the fixture, not directly into the test. Use this for matrix testing without writing a test loop.

Rule 12 — Skip / xfail / skipif

@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only path")
def test_unix_socket(): ...

@pytest.mark.xfail(reason="upstream issue #123, fix in next release")
def test_known_broken(): ...

if not docker_available():
    pytest.skip("docker not available", allow_module_level=True)

xfail differs from skip: xfail RUNS the test and asserts it fails; if it unexpectedly passes, that's a XPASS warning (now you can remove the marker). skip does not run the test at all. Prefer xfail(strict=True) if you want XPASS to fail the build.

Rule 13 — pytest.raises for expected exceptions

def test_invalid_topic_raises(client: Client) -> None:
    with pytest.raises(ValidationError, match="topic must not be empty"):
        client.publish("", b"x")

# For exception chains:
def test_chained(client: Client) -> None:
    with pytest.raises(NetworkError) as exc_info:
        client.publish("topic", b"x")
    assert isinstance(exc_info.value.__cause__, ConnectionError)

match uses re.search against str(exc). Always pin the match — a bare pytest.raises(Exception) masks unrelated breakage.

Rule 14 — Test doubles: prefer narrow fakes

For SDK test doubles, prefer in-memory fakes over unittest.mock.Mock when possible. Mocks check call patterns; fakes check behavior. Behavior is what consumers care about.

# Fake (preferred for non-trivial behavior)
class FakeBackend:
    def __init__(self) -> None:
        self.published: list[tuple[str, bytes]] = []
    async def post(self, url: str, data: bytes) -> None:
        self.published.append((url, data))

async def test_client_publish(monkeypatch) -> None:
    fake = FakeBackend()
    client = Client(Config(...), _backend=fake)
    await client.publish("topic", b"x")
    assert fake.published == [("https://...", b"x")]

Use unittest.mock.AsyncMock ONLY for narrow assertions on call patterns (was-it-called, was-it-called-with) — the kinds of assertions a behavioral fake cannot make.

Rule 15 — pytest.fixture vs module-level constants

# WRONG — module-level mutable state
TEST_CONFIG = Config(timeout=1.0)   # mutated by tests; leaks across tests

# RIGHT — fixture
@pytest.fixture
def test_config() -> Config:
    return Config(timeout=1.0)

Module-level constants are FINE for immutable values (e.g., EXAMPLE_PAYLOAD = b"hello"). Anything mutable, anything that holds resources, anything depending on environment — fixture.

GOOD: integration test against testcontainers

# tests/integration/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def postgres_url() -> Iterator[str]:
    with PostgresContainer("postgres:16") as pg:
        yield pg.get_connection_url()


# tests/integration/test_storage.py
import pytest
pytestmark = pytest.mark.integration

async def test_storage_roundtrip(postgres_url: str) -> None:
    storage = Storage(Config(db_url=postgres_url))
    async with storage:
        await storage.put("key", b"value")
        assert await storage.get("key") == b"value"

pytestmark at module level marks every test in the file with @pytest.mark.integration.

BAD anti-patterns

# 1. unittest.TestCase
class TestThing(unittest.TestCase):
    def test_x(self): self.assertEqual(...)
# Use pytest functions; plain `assert`.

# 2. for-loop in test
def test_validate():
    for input_ in [...]:
        assert is_valid(input_)
# Use @pytest.mark.parametrize.

# 3. Session-scoped mutable fixture
@pytest.fixture(scope="session")
def client() -> Client:
    return Client(...)            # cache leaks across tests

# 4. Direct os.environ mutation
def test_thing():
    os.environ["KEY"] = "x"        # leaks
    # use monkeypatch.setenv instead

# 5. Naked pytest.raises(Exception)
with pytest.raises(Exception):
    do_thing()                     # masks unrelated breakage

# 6. Unregistered marker
@pytest.mark.slow                  # warns; register in pyproject.toml

# 7. asyncio_mode=strict + missing decorator
async def test_async():            # silently doesn't run if mode=strict
    ...

# 8. Mocking what you should fake
mock = AsyncMock()
mock.publish.return_value = None
# Better: a small FakeBackend class with verifiable state.

pyproject.toml — pytest configuration template

[tool.pytest.ini_options]
minversion = "8.0"
addopts = [
    "-ra",                         # short summary for non-passing tests
    "--strict-markers",            # warn on unregistered markers
    "--strict-config",             # warn on misspelled config keys
    "--tb=short",
]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
markers = [
    "integration: requires testcontainers / docker",
    "slow: > 5s",
    "leak_hunt: leak-hunter targets",
    "perf: pytest-benchmark targets",
]
filterwarnings = [
    "error",                       # warnings are errors
    "ignore::DeprecationWarning:third_party_library.*",
]

filterwarnings = ["error"] is aggressive but pays off — surfaces deprecated APIs early. Allowlist deprecations from third-party deps you can't control; never broaden to "ignore::DeprecationWarning".

Cross-references

  • python-asyncio-patterns — patterns the async tests verify.
  • python-mock-strategy (when authored) — when to fake vs mock.
  • python-doctest-patterns — doctest examples in docstrings (run via pytest --doctest-modules).
  • tdd-patterns (shared) — RED/GREEN/REFACTOR cycle the tests follow.
  • sdk-existing-api-analyzer-python — how baseline test results are captured.