Pytest patterns for Python APIs -- httpx AsyncClient, conftest fixtures, database isolation, parametrize edge cases, error response testing, auth flows, factory fixtures
99
99%
Does it follow best practices?
Impact
100%
1.23xAverage score across 5 eval scenarios
Passed
No known issues
Patterns that catch real bugs in FastAPI and Flask APIs.
Use httpx.AsyncClient with ASGITransport. Never use requests or urllib.
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"Required packages:
pip install pytest httpx pytest-asyncio# tests/conftest.py
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app(testing=True)
with app.test_client() as c:
yield cPut fixtures in tests/conftest.py -- pytest discovers them automatically. Never import fixtures manually.
Fixture hierarchy for larger projects:
tests/
conftest.py # client, db reset, auth helpers
test_users.py
test_orders.py
orders/
conftest.py # order-specific factories
test_create.py
test_status.pyFixtures in a nested conftest.py are available only to tests in that directory and below. Put shared fixtures (client, db, auth) in the root tests/conftest.py.
Default to function scope. Each test gets a fresh fixture. This prevents test pollution.
@pytest.fixture # scope="function" is the default
async def client():
...Use session scope only for expensive, read-only setup like creating a test database schema:
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture(autouse=True)
def clean_tables(db_engine):
"""Truncate all tables before each test -- function-scoped."""
with db_engine.connect() as conn:
for table in reversed(Base.metadata.sorted_tables):
conn.execute(table.delete())
conn.commit()
yieldNever use session-scoped fixtures that hold mutable state. Tests will leak into each other.
Always reset database state before each test, not after. Tests that fail mid-way still leave a clean slate for the next test.
# tests/conftest.py
@pytest.fixture(autouse=True)
def clean_db(db_session):
"""Reset DB before each test. autouse=True means every test gets this."""
db_session.execute(text("DELETE FROM order_items"))
db_session.execute(text("DELETE FROM orders"))
db_session.execute(text("DELETE FROM users"))
db_session.commit()
yieldFor SQLAlchemy with FastAPI dependency override:
@pytest.fixture
def db_session(db_engine):
connection = db_engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
async def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()When multiple tests need different variations of test data, use factory fixtures instead of static fixtures. A factory fixture returns a callable that creates data on demand.
# tests/conftest.py
@pytest.fixture
def create_user(client):
"""Factory fixture -- call it to create a user with custom fields."""
created_ids = []
async def _create_user(**overrides):
defaults = {
"email": f"user{len(created_ids)}@test.com",
"name": "Test User",
"password": "securepass123",
}
defaults.update(overrides)
res = await client.post("/api/users", json=defaults)
assert res.status_code == 201, f"User creation failed: {res.text}"
user = res.json()["data"]
created_ids.append(user["id"])
return user
return _create_userUsage in tests:
async def test_users_have_unique_emails(client, create_user):
await create_user(email="alice@test.com")
res = await client.post("/api/users", json={
"email": "alice@test.com", "name": "Duplicate", "password": "pass123"
})
assert res.status_code == 409Use @pytest.mark.parametrize to test multiple inputs without duplicating test functions. Especially valuable for validation and error paths.
@pytest.mark.parametrize("payload,expected_status", [
({}, 422), # empty body
({"email": "not-an-email"}, 422), # invalid email
({"email": "a@b.com"}, 422), # missing required fields
({"email": "a@b.com", "name": "", "password": "x"}, 422), # blank name
({"email": "a@b.com", "name": "A", "password": "short"}, 422), # weak password
])
async def test_create_user_validation(client, payload, expected_status):
res = await client.post("/api/users", json=payload)
assert res.status_code == expected_status
body = res.json()
assert "detail" in body or "error" in bodyAlso use parametrize for testing multiple invalid status transitions, permission levels, or filter combinations.
Every endpoint needs error tests. Common mistake: testing only 200 responses.
Test these error cases for every resource endpoint:
class TestUserErrors:
async def test_get_nonexistent_returns_404(self, client):
res = await client.get("/api/users/99999")
assert res.status_code == 404
# Verify error response shape, not just status code
body = res.json()
assert "detail" in body or "error" in body
async def test_create_with_invalid_json_returns_422(self, client):
res = await client.post("/api/users", content="not json",
headers={"Content-Type": "application/json"})
assert res.status_code == 422
async def test_create_duplicate_returns_409(self, client, create_user):
await create_user(email="taken@test.com")
res = await client.post("/api/users", json={
"email": "taken@test.com", "name": "Dup", "password": "pass123"
})
assert res.status_code == 409
async def test_delete_nonexistent_returns_404(self, client):
res = await client.delete("/api/users/99999")
assert res.status_code == 404Assert the response body shape, not just status codes. A 404 that returns HTML instead of JSON will break your frontend.
Test protected endpoints both with and without valid credentials.
# tests/conftest.py
@pytest.fixture
async def auth_headers(client, create_user):
"""Get auth headers for a logged-in test user."""
user = await create_user(email="auth@test.com", password="testpass123")
res = await client.post("/api/auth/login", json={
"email": "auth@test.com", "password": "testpass123"
})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
class TestProtectedEndpoints:
async def test_requires_auth(self, client):
"""Endpoint without token returns 401."""
res = await client.get("/api/users/me")
assert res.status_code == 401
async def test_rejects_invalid_token(self, client):
res = await client.get("/api/users/me",
headers={"Authorization": "Bearer fake.token.here"})
assert res.status_code == 401
async def test_returns_user_with_valid_token(self, client, auth_headers):
res = await client.get("/api/users/me", headers=auth_headers)
assert res.status_code == 200
assert res.json()["data"]["email"] == "auth@test.com"Use monkeypatch (built into pytest) for replacing environment variables, attributes, and dictionary items. It automatically restores originals after the test.
def test_uses_test_database(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
# app code that reads os.getenv("DATABASE_URL") now sees the test value
async def test_email_service_failure(client, monkeypatch, create_user):
"""Simulate external service failure."""
def fake_send_email(*args, **kwargs):
raise ConnectionError("SMTP down")
monkeypatch.setattr("app.services.email.send_email", fake_send_email)
user = await create_user()
res = await client.post(f"/api/users/{user['id']}/verify")
assert res.status_code == 503 # graceful failure, not 500Use unittest.mock only when you need call-count assertions or complex side_effect sequences. Prefer monkeypatch for simple replacements.
Do not just check res.status_code == 200. Verify the response body has the expected structure.
async def test_list_users_response_shape(client, create_user):
await create_user()
res = await client.get("/api/users")
assert res.status_code == 200
body = res.json()
# Verify top-level structure
assert "data" in body
assert isinstance(body["data"], list)
assert len(body["data"]) > 0
# Verify each item has expected fields
user = body["data"][0]
assert "id" in user
assert "email" in user
assert "name" in user
# Verify sensitive fields are NOT exposed
assert "password" not in user
assert "password_hash" not in user| Mistake | Fix |
|---|---|
Using requests to test FastAPI | Use httpx.AsyncClient with ASGITransport |
scope="session" on a client fixture | Keep client fixture function-scoped |
No asyncio_mode = "auto" in config | Add to pyproject.toml or pytest.ini |
| Cleaning up after tests instead of before | Use autouse=True fixture that resets before yield |
| Only testing happy paths | Test 404, 422, 409, 401 for every resource |
| Asserting only status codes | Also assert response body shape and content |
| Importing fixtures from conftest | Never import -- pytest discovers them automatically |
| Mocking the database | Hit the real database, use :memory: SQLite or transactions |
| Hardcoded test data across tests | Use factory fixtures |
| Testing framework internals | Test your routes, not Pydantic/FastAPI behavior |