by thetestingacademy
Opinionated pytest patterns for AI agents - conftest fixture scoping, parametrize, markers, pyproject config, coverage, xdist parallelism, mocking, and AAA-structured test naming.
npx @qaskills/cli add pytest-best-practicesAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert Python test engineer. When the user asks you to write, refactor, or review pytest tests, follow these patterns exactly. Produce tests that are fast, isolated, readable, parametrized where it removes duplication, and configured through pyproject.toml rather than scattered defaults. Never write a test that depends on another test having run first.
assert lines are fine when they describe one outcome; testing two unrelated behaviors in one function is not.conftest.py fixtures for shared setup. Never use unittest-style setUp/tearDown in pytest code.function scope. Widen to module or session only for expensive, read-only resources (DB engine, app client).for loop inside a test hides which case failed. @pytest.mark.parametrize gives one test ID per case.pytest -p no:randomly off or with pytest-xdist must not change results. No shared mutable module state.pyproject.toml. Markers, test paths, addopts, and coverage settings are declared once, version-controlled, and apply to every developer and CI run.test_<unit>_<condition>_<expected> reads like a spec line in the report.--strict-markers -ra; a typo in a marker name must error, not silently skip.project/
src/
payments/
__init__.py
gateway.py
tests/
conftest.py # shared fixtures, root
unit/
conftest.py # unit-only fixtures
test_gateway.py
integration/
conftest.py # db engine, app client
test_checkout_flow.py
pyproject.toml
Keep tests/ outside src/ and mirror the package tree. Each layer gets its own conftest.py so fixtures cascade down but never leak up.
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
# -ra shows reasons for all non-passing; --strict-markers errors on unknown markers
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--import-mode=importlib",
"--showlocals",
]
markers = [
"slow: tests that take more than ~1s (deselect with '-m \"not slow\"')",
"integration: requires a live database or network",
"smoke: minimal critical-path suite for fast CI gating",
]
[tool.coverage.run]
branch = true
source = ["src"]
omit = ["*/__init__.py", "*/migrations/*"]
[tool.coverage.report]
fail_under = 85
show_missing = true
skip_covered = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
Install the toolchain pinned: pip install "pytest>=8" pytest-cov pytest-xdist pytest-mock.
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def db_engine():
"""Expensive: built once for the whole test session."""
from sqlalchemy import create_engine
engine = create_engine("sqlite:///:memory:", future=True)
_create_schema(engine)
yield engine
engine.dispose()
@pytest.fixture(scope="function")
def db_session(db_engine):
"""Cheap and isolated: a transaction per test, rolled back after."""
from sqlalchemy.orm import Session
connection = db_engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
try:
yield session
finally:
session.close()
transaction.rollback() # undo every write this test made
connection.close()
@pytest.fixture
def frozen_time(monkeypatch):
"""Pin the clock so time-based logic is deterministic."""
import payments.gateway as gw
class _Clock:
now = 1_700_000_000.0
monkeypatch.setattr(gw.time, "time", lambda: _Clock.now)
return _Clock
Scope rules in practice: the db_engine is created once (session), but every test gets a fresh db_session (function) wrapped in a transaction that is rolled back. This is the fastest correct way to keep DB tests isolated.
When a test needs many similar objects, yield a factory instead of a single value:
@pytest.fixture
def make_order():
created = []
def _make(amount=100, currency="USD", status="pending"):
order = {"amount": amount, "currency": currency, "status": status}
created.append(order)
return order
yield _make
created.clear() # teardown
def test_refund_rejects_pending_order(make_order):
order = make_order(status="pending")
assert order["status"] == "pending"
import pytest
from payments.gateway import normalize_amount, InvalidAmount
@pytest.mark.parametrize(
("raw", "expected"),
[
("100", 10000), # dollars -> cents
("100.5", 10050),
("0.01", 1),
("1_000", 100000),
],
ids=["whole", "half", "min", "underscored"],
)
def test_normalize_amount_converts_to_cents(raw, expected):
assert normalize_amount(raw) == expected
@pytest.mark.parametrize("bad", ["-1", "abc", "", None])
def test_normalize_amount_rejects_invalid(bad):
with pytest.raises(InvalidAmount):
normalize_amount(bad)
# Stacking parametrize multiplies the cases (cartesian product: 2 x 2 = 4 tests)
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [100, 250])
def test_charge_supports_currency_and_amount(currency, amount):
result = normalize_amount(str(amount))
assert result > 0
Always pass ids= for non-trivial values so the report reads test_normalize_amount_converts_to_cents[half] instead of [100.5-10050].
import pytest
@pytest.mark.smoke
def test_health_endpoint_returns_200(client):
assert client.get("/health").status_code == 200
@pytest.mark.slow
@pytest.mark.integration
def test_full_checkout_persists_order(db_session, client):
resp = client.post("/checkout", json={"amount": "49.99"})
assert resp.status_code == 201
saved = db_session.query("orders").first()
assert saved is not None
Run subsets:
pytest -m smoke # fast gate
pytest -m "not slow" # local default
pytest -m "integration and not slow"
Because --strict-markers is set, @pytest.mark.smok (typo) raises an error instead of silently registering a new marker.
def test_charge_calls_gateway_once(mocker):
# Patch where the name is LOOKED UP, not where requests.post is defined.
mock_post = mocker.patch("payments.gateway.requests.post")
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {"id": "ch_123", "paid": True}
from payments.gateway import charge
result = charge(amount_cents=4999, token="tok_visa")
assert result["id"] == "ch_123"
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs["json"]["amount"] == 4999
def test_charge_retries_on_timeout(mocker):
import requests
mock_post = mocker.patch("payments.gateway.requests.post")
mock_post.side_effect = [requests.Timeout(), mocker.Mock(status_code=200)]
from payments.gateway import charge_with_retry
charge_with_retry(amount_cents=100, token="tok")
assert mock_post.call_count == 2
Prefer mocker (the pytest-mock fixture) over bare unittest.mock.patch decorators - it auto-undoes patches at teardown and reads cleaner inside the AAA body.
# Coverage with branch tracking; fails the run if under fail_under (85%)
pytest --cov --cov-report=term-missing
# Parallel across all CPU cores (pytest-xdist). Use for the full suite.
pytest -n auto
# Combine, but note: coverage + xdist needs the cov plugin to merge workers
pytest -n auto --cov --cov-report=xml
For xdist to be safe, tests must not write to shared files or fixed ports. Use the tmp_path fixture for files and bind to port 0 for servers so the OS assigns a free port per worker.
def test_writes_report_to_isolated_dir(tmp_path):
report = tmp_path / "out.json"
report.write_text('{"ok": true}')
assert report.read_text() == '{"ok": true}'
import pytest
def test_divide_raises_with_message():
with pytest.raises(ZeroDivisionError, match="division by zero"):
1 / 0
def test_deprecated_api_warns():
with pytest.warns(DeprecationWarning, match="use charge_v2"):
legacy_charge(100)
def test_approx_for_floats():
assert 0.1 + 0.2 == pytest.approx(0.3)
Always pass match= to pytest.raises so a different error with the wrong message does not pass the test silently.
function scope as the default. Only widen a fixture's scope when profiling proves the setup is a bottleneck and the resource is read-only.id. Failure reports become self-documenting.pyproject.toml. With --strict-markers, this catches typos and documents the suite's vocabulary.mocker.patch("mypkg.module.requests"), never mocker.patch("requests"), unless the module imports the whole requests module.tmp_path and tmp_path_factory for all filesystem work. Never write into the repo or /tmp directly.-n auto in CI for the full suite, single-process for debugging. Parallel runs surface hidden ordering dependencies.fail_under in coverage config, not in the CI script. The threshold travels with the repo.pytest.approx for floats and match= for exceptions. Exact float equality and bare raises are the two most common false-pass sources.session-scoped mutable fixtures. A shared list or dict at session scope leaks state between tests and breaks under -n auto.time.sleep() to wait for async work. Mock the clock or poll a condition. Sleeps make suites slow and flaky.test_everything function. If it has three Act blocks, it is three tests wearing a trench coat. Split it.except. Use pytest.raises; a try/except that never raises will pass silently.port=0, tmp_path, and a frozen-time fixture.Trigger when the user is working in a Python codebase and asks to:
conftest.py, fixtures, or fixture scopingpyproject.tomlpytest-mock / monkeypatchDo not trigger for JavaScript/TypeScript test frameworks (Jest, Vitest) or for non-pytest Python frameworks unless the user explicitly asks to migrate them to pytest.
- name: Install QA Skills
run: npx @qaskills/cli add pytest-best-practices12 of 29 agents supported