| name | Pytest Best Practices |
| description | Opinionated pytest patterns for AI agents - conftest fixture scoping, parametrize, markers, pyproject config, coverage, xdist parallelism, mocking, and AAA-structured test naming. |
| version | 1.0.0 |
| author | thetestingacademy |
| license | MIT |
| tags | ["pytest","python","fixtures","parametrize","conftest","markers","coverage","mocking","xdist"] |
| testingTypes | ["unit","integration","regression"] |
| frameworks | ["pytest"] |
| languages | ["python"] |
| domains | ["api","web","backend"] |
| agents | ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"] |
Pytest Best Practices Skill
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.
Core Principles
- One assertion concept per test. A test verifies a single behavior. Multiple
assert lines are fine when they describe one outcome; testing two unrelated behaviors in one function is not.
- Arrange-Act-Assert, visibly. Structure every test body in three blocks separated by blank lines. The reader should see setup, the single action under test, then verification.
- Fixtures over setup methods. Use
conftest.py fixtures for shared setup. Never use unittest-style setUp/tearDown in pytest code.
- Scope fixtures as narrowly as correctness allows. Default to
function scope. Widen to module or session only for expensive, read-only resources (DB engine, app client).
- Parametrize instead of looping. A
for loop inside a test hides which case failed. @pytest.mark.parametrize gives one test ID per case.
- Tests are isolated and order-independent. Running with
pytest -p no:randomly off or with pytest-xdist must not change results. No shared mutable module state.
- Mock at the boundary you own. Patch where the name is looked up, not where it is defined. Mock network, time, and filesystem; never mock the unit under test.
- Configuration lives in
pyproject.toml. Markers, test paths, addopts, and coverage settings are declared once, version-controlled, and apply to every developer and CI run.
- Name tests as behavior sentences.
test_<unit>_<condition>_<expected> reads like a spec line in the report.
- Fail fast in CI, explore locally. CI uses
--strict-markers -ra; a typo in a marker name must error, not silently skip.
Project Layout
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.
pyproject.toml Configuration
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
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.
conftest.py and Fixture Scopes
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()
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.
Fixture Factories
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()
def test_refund_rejects_pending_order(make_order):
order = make_order(status="pending")
assert order["status"] == "pending"
Parametrize: Cases, IDs, and Stacking
import pytest
from payments.gateway import normalize_amount, InvalidAmount
@pytest.mark.parametrize(
("raw", "expected"),
[
("100", 10000),
("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)
@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].
Markers and Selective Runs
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
pytest -m "not slow"
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.
Mocking with pytest-mock
def test_charge_calls_gateway_once(mocker):
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 and Parallel Execution
pytest --cov --cov-report=term-missing
pytest -n auto
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}'
Testing Exceptions and Warnings
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.
Best Practices
- Keep
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.
- Roll back, do not truncate. For DB tests, wrap each test in a transaction and roll back. It is faster and safer than deleting rows in teardown.
- Give every parametrized case an
id. Failure reports become self-documenting.
- Declare every marker in
pyproject.toml. With --strict-markers, this catches typos and documents the suite's vocabulary.
- Patch at the point of use.
mocker.patch("mypkg.module.requests"), never mocker.patch("requests"), unless the module imports the whole requests module.
- Use
tmp_path and tmp_path_factory for all filesystem work. Never write into the repo or /tmp directly.
- Run
-n auto in CI for the full suite, single-process for debugging. Parallel runs surface hidden ordering dependencies.
- Set
fail_under in coverage config, not in the CI script. The threshold travels with the repo.
- Prefer
pytest.approx for floats and match= for exceptions. Exact float equality and bare raises are the two most common false-pass sources.
- Use fixture factories when a test needs N similar objects. A factory keeps each test explicit about the data it depends on.
Anti-Patterns to Avoid
- Looping over cases inside one test. When case 3 of 10 fails, you lose which one and the rest never run. Parametrize instead.
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.
- Asserting on log output as the primary check. Logs are not a contract. Assert on return values and state; check logs only when logging is the feature.
- Importing the module under test at the top when you need to patch its dependencies. Import inside the test (after patching) or patch the attribute on the already-imported module.
- One giant
test_everything function. If it has three Act blocks, it is three tests wearing a trench coat. Split it.
- Catching the exception yourself with try/except and asserting in
except. Use pytest.raises; a try/except that never raises will pass silently.
- Hardcoded ports, paths, or timestamps. These break parallel runs and CI. Use
port=0, tmp_path, and a frozen-time fixture.
When to Trigger This Skill
Trigger when the user is working in a Python codebase and asks to:
- Write, scaffold, or refactor pytest tests
- Set up
conftest.py, fixtures, or fixture scoping
- Add parametrized cases or custom markers
- Configure pytest, coverage, or parallel runs in
pyproject.toml
- Fix flaky, slow, or order-dependent Python tests
- Add mocking with
pytest-mock / monkeypatch
Do 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.