Pytest Playbook — Real-World Recipes
Copy-paste templates for common testing scenarios.
1) FastAPI Endpoint Test
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_create_user(async_client: AsyncClient) -> None:
"""Assumes async_client fixture from conftest.py (see below)."""
payload = {"email": "alice@example.com", "name": "Alice"}
response = await async_client.post("/users", json=payload)
assert response.status_code == 201
assert response.json()["email"] == payload["email"]
FastAPI conftest.py fixture:
import pytest
from httpx import ASGITransport, AsyncClient
from myapp.main import app
@pytest.fixture
async def async_client():
"""Test client that talks directly to the ASGI app (no network)."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
2) Service Test with Repository Mock
def test_checkout_total(mocker):
# mocker.Mock() creates a lightweight stand-in for the repository
repo = mocker.Mock()
repo.get_items.return_value = [{"price": 10}, {"price": 5}]
service = CheckoutService(repo=repo)
assert service.total("order-1") == 15
repo.get_items.assert_called_once_with("order-1")
3) SQLAlchemy Rollback Per Test
import pytest
from sqlalchemy.orm import Session
@pytest.fixture
def db_session(engine):
"""Each test runs inside a transaction that rolls back on teardown."""
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
4) CLI Test (Click / Typer)
from click.testing import CliRunner
from myapp.cli import app
def test_cli_dry_run():
runner = CliRunner()
result = runner.invoke(app, ["sync", "--dry-run"])
assert result.exit_code == 0
assert "completed" in result.output.lower()
5) Background Task Trigger
def test_enqueues_job(mocker):
# verify the task is dispatched, not that email is actually sent
enqueue = mocker.patch("myapp.tasks.enqueue_email")
send_welcome_email("alice@example.com")
enqueue.assert_called_once_with("alice@example.com")
6) Retry Logic
from myapp.exceptions import ServiceUnavailable
def test_retry_on_503(mocker):
# side_effect list: first call raises, second returns success
call = mocker.Mock(side_effect=[ServiceUnavailable(), {"ok": True}])
result = fetch_with_retry(call, retries=2)
assert result["ok"] is True
assert call.call_count == 2
7) Contract Shape Test
def test_user_response_has_required_fields(client):
"""Guard against accidental field removal in API responses."""
response = client.get("/users/1")
body = response.json()
assert response.status_code == 200
# <= checks subset: all required keys must be present
assert {"id", "email", "name"} <= set(body.keys())
8) Time Freeze
def test_token_expiry(freezer):
"""Requires pytest-freezegun. Modern alternative: time-machine (faster, C-based)."""
freezer.move_to("2026-04-01T10:00:00Z")
token = issue_token(ttl_seconds=60)
freezer.move_to("2026-04-01T10:01:01Z") # 61s later
assert is_token_expired(token) is True
Quick Checklist
- Prefer fixture-driven setup over inline setup.
- Mock boundaries (HTTP, DB adapters, SMTP), not core business logic.
- Name tests as
test_<action>_<expected_result>. - Use deterministic data (fixed IDs, fixed timestamps).
- Import what you use — keep examples self-contained.