FastAPI Integration Testing with TestClient and Real Dependencies
Unit tests verify individual functions. Integration tests verify that your application works when all the pieces are connected. For FastAPI applications, integration testing means running your endpoints against real databases, real caches, and real external services — not mocks. This is where you find the bugs that only appear in production: connection pool exhaustion, transaction isolation failures, serialization mismatches, and race conditions in background workers.
TestClient vs httpx.AsyncClient
FastAPI's TestClient is built on requests and runs synchronously. It's simpler to use but has a fundamental limitation: it can't test truly async behavior. httpx.AsyncClient is the async alternative that lets you write tests that reflect how real HTTP clients interact with your API.
# Synchronous — TestClient
from fastapi.testclient import TestClient
from app.main import app
def test_sync_endpoint():
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
# Client session doesn't persist between calls# Asynchronous — httpx.AsyncClient
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest_asyncio.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as c:
yield c
async def test_async_endpoint(client):
response = await client.get("/health")
assert response.status_code == 200Use TestClient when:
- Tests are synchronous (no
async def) - You're testing simple request/response behavior
- You want simplicity and fast iteration
- You're in a codebase that doesn't use async fixtures
Use AsyncClient when:
- You need to test concurrent requests
- You're testing WebSocket endpoints
- Your test fixtures are async (database sessions, etc.)
- You're testing streaming responses
- You need fine-grained control over connection behavior
# The hybrid approach: AsyncClient for async tests, TestClient for sync
# conftest.py
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
def sync_client():
with TestClient(app) as client:
yield client
@pytest_asyncio.fixture
async def async_client():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
timeout=30.0
) as client:
yield clientTesting with Real Redis
Mocking Redis gives you false confidence. The mock will happily return whatever you configure, regardless of whether your actual Redis commands are correct. Real Redis integration tests catch serialization bugs, key naming issues, and TTL logic errors.
# docker-compose.test.yml
version: "3.8"
services:
redis-test:
image: redis:7-alpine
ports:
- "6380:6379" # Different port to avoid conflicts
command: redis-server --appendonly no --save "" # No persistence for tests# tests/conftest.py
import pytest
import pytest_asyncio
import redis.asyncio as aioredis
from app.main import app
from app.dependencies import get_redis
TEST_REDIS_URL = "redis://localhost:6380"
@pytest_asyncio.fixture(scope="session")
async def redis_client():
"""Session-scoped Redis client for all tests."""
client = await aioredis.from_url(TEST_REDIS_URL, decode_responses=True)
yield client
await client.aclose()
@pytest_asyncio.fixture(autouse=True)
async def flush_redis(redis_client):
"""Flush Redis before each test for clean state."""
await redis_client.flushdb()
yield
# Optional: flush after too, for debugging
@pytest_asyncio.fixture
async def app_client(redis_client):
"""HTTP client connected to the app with real Redis."""
app.dependency_overrides[get_redis] = lambda: redis_client
from httpx import AsyncClient, ASGITransport
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as client:
yield client
app.dependency_overrides.clear()# tests/test_caching.py
import asyncio
async def test_cache_hit_served_from_redis(app_client, redis_client):
"""First call populates cache, second call should be faster and from cache."""
# Cold cache
response1 = await app_client.get("/products/1")
assert response1.status_code == 200
assert response1.headers.get("x-cache") == "MISS"
# Verify key was set in Redis
cached = await redis_client.get("product:1")
assert cached is not None
# Warm cache
response2 = await app_client.get("/products/1")
assert response2.status_code == 200
assert response2.headers.get("x-cache") == "HIT"
assert response2.json() == response1.json()
async def test_cache_expires_after_ttl(app_client, redis_client):
await app_client.get("/products/1") # populate cache
# Get the TTL
ttl = await redis_client.ttl("product:1")
assert 0 < ttl <= 300 # Should expire within 5 minutes
async def test_cache_invalidated_on_update(app_client, redis_client):
# Populate cache
await app_client.get("/products/1")
assert await redis_client.exists("product:1")
# Update the product
response = await app_client.patch(
"/products/1",
json={"price": 99.99}
)
assert response.status_code == 200
# Cache should be invalidated
assert not await redis_client.exists("product:1")
async def test_rate_limiting_persists_across_requests(app_client, redis_client):
"""Rate limit counters should accumulate in real Redis."""
for i in range(10):
response = await app_client.get(
"/api/limited",
headers={"X-Client-ID": "test-client"}
)
if i < 5:
assert response.status_code == 200
else:
assert response.status_code == 429
# Verify counter in Redis
count = await redis_client.get("ratelimit:test-client")
assert int(count) > 5Docker Compose for Test Infrastructure
For integration tests that need multiple services, Docker Compose provides a reproducible environment that matches production:
# docker-compose.test.yml
version: "3.8"
services:
postgres-test:
image: postgres:15-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
redis-test:
image: redis:7-alpine
ports:
- "6380:6379"
minio-test:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
retries: 10# Makefile
test-integration:
docker-compose -f docker-compose.test.yml up -d
sleep 5 # Wait for services
pytest tests/integration/ -v
docker-compose -f docker-compose.test.yml downOr use pytest-docker for automatic lifecycle management:
# conftest.py (with pytest-docker)
import pytest
@pytest.fixture(scope="session")
def docker_compose_file():
return "docker-compose.test.yml"
@pytest.fixture(scope="session")
def docker_services_project_name():
return "myapp_test"
@pytest.fixture(scope="session")
def postgres_url(docker_ip, docker_services):
port = docker_services.port_for("postgres-test", 5432)
docker_services.wait_until_responsive(
timeout=30,
pause=1,
check=lambda: is_responsive(f"postgresql://postgres:testpassword@{docker_ip}:{port}/testdb")
)
return f"postgresql+asyncpg://postgres:testpassword@{docker_ip}:{port}/testdb"Testing Webhooks
Webhooks are one of the hardest things to test because your application is the server, not the client. You need to simulate receiving a webhook payload:
# app/routers/webhooks.py
import hmac
import hashlib
from fastapi import APIRouter, Request, HTTPException, Header
from app.services import OrderService
router = APIRouter()
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
@router.post("/webhooks/stripe")
async def handle_stripe_webhook(
request: Request,
stripe_signature: str = Header(..., alias="stripe-signature"),
order_service: OrderService = Depends(get_order_service),
):
payload = await request.body()
if not verify_webhook_signature(payload, stripe_signature, STRIPE_WEBHOOK_SECRET):
raise HTTPException(status_code=400, detail="Invalid webhook signature")
event = parse_stripe_event(payload)
if event["type"] == "payment_intent.succeeded":
await order_service.fulfill_order(event["data"]["object"]["metadata"]["order_id"])
elif event["type"] == "payment_intent.payment_failed":
await order_service.mark_payment_failed(event["data"]["object"]["metadata"]["order_id"])
return {"status": "received"}# tests/test_webhooks.py
import hmac
import hashlib
import json
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, MagicMock
from app.main import app
from app.dependencies import get_order_service
WEBHOOK_SECRET = "test_webhook_secret"
def make_stripe_signature(payload: bytes, secret: str = WEBHOOK_SECRET) -> str:
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return f"sha256={sig}"
def stripe_event(event_type: str, order_id: str = "order-123") -> dict:
return {
"type": event_type,
"data": {
"object": {
"id": "pi_test_123",
"amount": 5000,
"metadata": {"order_id": order_id}
}
}
}
@pytest.fixture
def mock_order_service():
service = MagicMock()
service.fulfill_order = AsyncMock()
service.mark_payment_failed = AsyncMock()
return service
@pytest.fixture
def client(mock_order_service):
app.dependency_overrides[get_order_service] = lambda: mock_order_service
yield TestClient(app)
app.dependency_overrides.clear()
class TestStripeWebhook:
def test_payment_succeeded_fulfills_order(self, client, mock_order_service):
payload = json.dumps(stripe_event("payment_intent.succeeded")).encode()
sig = make_stripe_signature(payload)
response = client.post(
"/webhooks/stripe",
content=payload,
headers={
"stripe-signature": sig,
"content-type": "application/json"
}
)
assert response.status_code == 200
mock_order_service.fulfill_order.assert_called_once_with("order-123")
def test_payment_failed_marks_order_failed(self, client, mock_order_service):
payload = json.dumps(stripe_event("payment_intent.payment_failed")).encode()
sig = make_stripe_signature(payload)
response = client.post(
"/webhooks/stripe",
content=payload,
headers={"stripe-signature": sig, "content-type": "application/json"}
)
assert response.status_code == 200
mock_order_service.mark_payment_failed.assert_called_once_with("order-123")
def test_invalid_signature_rejected(self, client, mock_order_service):
payload = json.dumps(stripe_event("payment_intent.succeeded")).encode()
response = client.post(
"/webhooks/stripe",
content=payload,
headers={
"stripe-signature": "sha256=invalidsignature",
"content-type": "application/json"
}
)
assert response.status_code == 400
mock_order_service.fulfill_order.assert_not_called()
def test_missing_signature_rejected(self, client):
payload = json.dumps(stripe_event("payment_intent.succeeded")).encode()
response = client.post(
"/webhooks/stripe",
content=payload,
headers={"content-type": "application/json"}
)
assert response.status_code == 422 # Missing required header
def test_tampered_payload_rejected(self, client):
original = json.dumps(stripe_event("payment_intent.succeeded")).encode()
sig = make_stripe_signature(original)
# Tamper with the payload after signing
tampered = original.replace(b"order-123", b"order-999")
response = client.post(
"/webhooks/stripe",
content=tampered,
headers={"stripe-signature": sig, "content-type": "application/json"}
)
assert response.status_code == 400Testing File Uploads
File upload endpoints need tests for valid files, oversized files, wrong MIME types, and empty files:
# app/routers/uploads.py
from fastapi import APIRouter, File, UploadFile, HTTPException
from app.services import FileStorageService
router = APIRouter()
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "application/pdf"}
@router.post("/documents/upload")
async def upload_document(
file: UploadFile = File(...),
storage: FileStorageService = Depends(get_storage),
):
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail=f"Unsupported file type: {file.content_type}"
)
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"File too large. Max size: {MAX_FILE_SIZE} bytes"
)
if len(content) == 0:
raise HTTPException(status_code=400, detail="File is empty")
url = await storage.store(
filename=file.filename,
content=content,
content_type=file.content_type
)
return {"url": url, "size": len(content), "filename": file.filename}# tests/test_file_uploads.py
import pytest
import io
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, MagicMock
from PIL import Image
from app.main import app
from app.dependencies import get_storage
def make_storage_mock(url="https://cdn.example.com/test-file.jpg"):
storage = MagicMock()
storage.store = AsyncMock(return_value=url)
return storage
def make_image_bytes(format="JPEG", size=(100, 100)) -> bytes:
"""Create a real in-memory image for testing."""
img = Image.new("RGB", size, color=(73, 109, 137))
buf = io.BytesIO()
img.save(buf, format=format)
return buf.getvalue()
@pytest.fixture
def client():
storage = make_storage_mock()
app.dependency_overrides[get_storage] = lambda: storage
yield TestClient(app), storage
app.dependency_overrides.clear()
class TestFileUpload:
def test_valid_jpeg_upload_succeeds(self, client):
test_client, storage = client
image_bytes = make_image_bytes("JPEG")
response = test_client.post(
"/documents/upload",
files={"file": ("photo.jpg", image_bytes, "image/jpeg")}
)
assert response.status_code == 200
body = response.json()
assert "url" in body
assert body["filename"] == "photo.jpg"
assert body["size"] == len(image_bytes)
storage.store.assert_called_once()
def test_valid_png_upload_succeeds(self, client):
test_client, _ = client
png_bytes = make_image_bytes("PNG")
response = test_client.post(
"/documents/upload",
files={"file": ("image.png", png_bytes, "image/png")}
)
assert response.status_code == 200
def test_unsupported_type_rejected(self, client):
test_client, storage = client
response = test_client.post(
"/documents/upload",
files={"file": ("malware.exe", b"MZ\x90\x00", "application/octet-stream")}
)
assert response.status_code == 415
assert "Unsupported file type" in response.json()["detail"]
storage.store.assert_not_called()
def test_oversized_file_rejected(self, client):
test_client, storage = client
# 11MB of data
large_content = b"x" * (11 * 1024 * 1024)
response = test_client.post(
"/documents/upload",
files={"file": ("large.jpg", large_content, "image/jpeg")}
)
assert response.status_code == 413
storage.store.assert_not_called()
def test_empty_file_rejected(self, client):
test_client, storage = client
response = test_client.post(
"/documents/upload",
files={"file": ("empty.jpg", b"", "image/jpeg")}
)
assert response.status_code == 400
assert "empty" in response.json()["detail"].lower()
def test_no_file_returns_422(self, client):
test_client, _ = client
response = test_client.post("/documents/upload")
assert response.status_code == 422
def test_multiple_file_upload(self, client):
test_client, storage = client
images = [make_image_bytes("JPEG") for _ in range(3)]
files = [
("files", (f"photo{i}.jpg", img, "image/jpeg"))
for i, img in enumerate(images)
]
response = test_client.post("/documents/upload-batch", files=files)
assert response.status_code == 200
assert len(response.json()["uploaded"]) == 3
assert storage.store.call_count == 3End-to-End API Flow Tests
E2E tests follow a complete user journey through multiple endpoints:
# tests/integration/test_order_flow.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest_asyncio.fixture
async def client():
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver"
) as c:
yield c
async def get_auth_token(client, email, password):
response = await client.post("/auth/token", data={
"username": email,
"password": password,
"grant_type": "password"
})
assert response.status_code == 200, f"Auth failed: {response.json()}"
return response.json()["access_token"]
async def test_complete_order_flow(client):
"""
Complete happy-path order flow:
1. Register a new user
2. Browse products
3. Add items to cart
4. Checkout
5. Verify order confirmation
6. Verify inventory updated
"""
# Step 1: Register
reg_response = await client.post("/users/register", json={
"email": "buyer@example.com",
"password": "securepass123",
"name": "Test Buyer"
})
assert reg_response.status_code == 201
user_id = reg_response.json()["id"]
# Step 2: Authenticate
token = await get_auth_token(client, "buyer@example.com", "securepass123")
headers = {"Authorization": f"Bearer {token}"}
# Step 3: Browse products (unauthenticated works too)
products_response = await client.get("/products?category=electronics")
assert products_response.status_code == 200
products = products_response.json()["items"]
assert len(products) > 0
product_id = products[0]["id"]
initial_stock = products[0]["stock"]
# Step 4: Add to cart
cart_response = await client.post(
"/cart/items",
json={"product_id": product_id, "quantity": 2},
headers=headers
)
assert cart_response.status_code == 200
cart_id = cart_response.json()["cart_id"]
# Verify cart contents
cart = await client.get(f"/cart/{cart_id}", headers=headers)
assert cart.json()["item_count"] == 2
assert cart.json()["total"] > 0
# Step 5: Checkout
order_response = await client.post(
f"/cart/{cart_id}/checkout",
json={
"shipping_address": {
"line1": "123 Test St",
"city": "Testville",
"country": "US",
"postal_code": "12345"
},
"payment_method_id": "pm_card_visa" # Stripe test token
},
headers=headers
)
assert order_response.status_code == 201
order_id = order_response.json()["id"]
assert order_response.json()["status"] == "confirmed"
# Step 6: Verify order is retrievable
order = await client.get(f"/orders/{order_id}", headers=headers)
assert order.status_code == 200
assert order.json()["user_id"] == user_id
assert order.json()["status"] == "confirmed"
# Step 7: Verify inventory was decremented
product = await client.get(f"/products/{product_id}")
updated_stock = product.json()["stock"]
assert updated_stock == initial_stock - 2
async def test_order_flow_fails_gracefully_on_payment_failure(client):
"""Verify cart is preserved when payment fails."""
token = await get_auth_token(client, "buyer@example.com", "securepass123")
headers = {"Authorization": f"Bearer {token}"}
# Add item to cart
cart_response = await client.post(
"/cart/items",
json={"product_id": 1, "quantity": 1},
headers=headers
)
cart_id = cart_response.json()["cart_id"]
# Attempt checkout with failing payment method
order_response = await client.post(
f"/cart/{cart_id}/checkout",
json={
"shipping_address": {"line1": "123 St", "city": "City", "country": "US", "postal_code": "00000"},
"payment_method_id": "pm_card_chargeDeclined" # Stripe test card that declines
},
headers=headers
)
assert order_response.status_code == 402 # Payment Required
assert "payment" in order_response.json()["detail"].lower()
# Cart should still be intact
cart = await client.get(f"/cart/{cart_id}", headers=headers)
assert cart.status_code == 200
assert cart.json()["item_count"] == 1Running Integration Tests in CI
Separate integration tests from unit tests using markers:
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line(
"markers", "integration: marks tests as integration tests (requires external services)"
)# Run only unit tests (fast, no infrastructure needed)
pytest -m "not integration" tests/
# Run only integration tests
pytest -m integration tests/integration/
# Run everything
pytest tests/# .github/workflows/test.yml
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -e ".[test]"
- run: pytest -m "not integration" --cov=app
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 10
redis:
image: redis:7
options: --health-cmd "redis-cli ping"
steps:
- uses: actions/checkout@v4
- run: pip install -e ".[test]"
- run: pytest -m integration --cov=app
env:
DATABASE_URL: postgresql+asyncpg://postgres:testpassword@localhost/testdb
REDIS_URL: redis://localhost:6379The split between unit and integration tests lets you run fast feedback in feature branches and full coverage in main. The integration tests are slower but catch the class of bugs that mocks can never find — and those are exactly the bugs that reach production.