Stage 7 · Master
HTTP APIs & REST
API Testing
pytest with httpx.TestClient, mock servers, and contract testing.
httpx TestClient
httpx provides a TestClient that wraps your FastAPI app and makes requests without starting a real server. This makes API tests fast and deterministic.
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
app = FastAPI()
@app.get("/health")
def health():
return {"status": "healthy"}
# Create test client
client = TestClient(app)
def test_health_endpoint():
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "healthy"}
def test_health_returns_json():
resp = client.get("/health")
assert resp.headers["content-type"] == "application/json"TestClient sends requests directly to the ASGI app without network overhead. Tests run in milliseconds. No server startup or teardown required.
Testing FastAPI Endpoints
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Server(BaseModel):
name: str
ip: str
servers: dict[str, Server] = {}
@app.post("/servers")
def create_server(server: Server):
servers[server.name] = server
return {"status": "created", "name": server.name}
@app.get("/servers/{name}")
def get_server(name: str):
if name not in servers:
raise HTTPException(status_code=404, detail="Not found")
return servers[name]
client = TestClient(app)
def test_create_and_get_server():
# Create
resp = client.post("/servers", json={"name": "web-01", "ip": "10.0.1.1"})
assert resp.status_code == 200
assert resp.json()["name"] == "web-01"
# Get
resp = client.get("/servers/web-01")
assert resp.status_code == 200
assert resp.json()["ip"] == "10.0.1.1"
def test_get_nonexistent_server():
resp = client.get("/servers/nonexistent")
assert resp.status_code == 404
def test_create_server_validation_error():
# Missing required field
resp = client.post("/servers", json={"name": "web-01"})
assert resp.status_code == 422Test CRUD flows end-to-end: create, read, update, delete. Test validation errors (422) for invalid input. Test not-found errors (404) for missing resources.
Mocking External Services
import httpx
import respx
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/service-status/{name}")
async def service_status(name: str):
async with httpx.AsyncClient() as client:
resp = await client.get(f"http://internal-api:8080/health/{name}")
resp.raise_for_status()
return resp.json()
client = TestClient(app)
@respx.mock
def test_service_status():
# Mock the external API call
respx.get("http://internal-api:8080/health/web-01").mock(
return_value=httpx.Response(200, json={"status": "healthy"})
)
resp = client.get("/service-status/web-01")
assert resp.status_code == 200
assert resp.json()["status"] == "healthy"
@respx.mock
def test_external_service_down():
respx.get("http://internal-api:8080/health/web-01").mock(
return_value=httpx.Response(503, json={"error": "unavailable"})
)
resp = client.get("/service-status/web-01")
assert resp.status_code == 503respx intercepts httpx requests and returns mock responses. This lets you test error scenarios without actually taking down external services.
Contract Testing
from pydantic import BaseModel, ValidationError
import pytest
# Define expected response schema
class HealthResponse(BaseModel):
status: str
uptime: float
version: str
class ErrorResponse(BaseModel):
error: str
code: int
def test_api_contract(base_url: str = "http://localhost:8000"):
"""Verify the API returns responses matching our schema."""
import httpx
# Test health endpoint contract
resp = httpx.get(f"{base_url}/health")
try:
HealthResponse(**resp.json())
except ValidationError as e:
pytest.fail(f"Health response does not match schema: {e}")
# Test error contract
resp = httpx.get(f"{base_url}/nonexistent")
try:
ErrorResponse(**resp.json())
except ValidationError as e:
pytest.fail(f"Error response does not match schema: {e}")Contract testing ensures API responses match the expected schema. If the API changes its response structure, the test fails. This catches breaking changes before they reach production.
Test Organization
# tests/conftest.py — shared fixtures
import pytest
from fastapi.testclient import TestClient
from myapp.main import app
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def sample_server():
return {"name": "test-server", "ip": "10.0.0.1"}
# tests/test_health.py
def test_health(client):
resp = client.get("/health")
assert resp.status_code == 200
# tests/test_servers.py
def test_create_server(client, sample_server):
resp = client.post("/servers", json=sample_server)
assert resp.status_code == 200
def test_get_server(client, sample_server):
client.post("/servers", json=sample_server)
resp = client.get("/servers/test-server")
assert resp.json()["name"] == "test-server"
# tests/test_deploy.py
def test_deploy(client):
resp = client.post("/deploy", json={
"service": "web-01", "image": "nginx:latest", "replicas": 3
})
assert resp.status_code == 200Separate tests by domain (health, servers, deploy). Use conftest.py for shared fixtures. This makes tests discoverable and maintainable.
CI Integration
name: API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest tests/ -v --cov=myapp --cov-report=xml
- uses: codecov/codecov-action@v4
with:
file: coverage.xmlUse services in GitHub Actions to spin up dependencies like databases and message queues. Tests run against real services in a clean environment.
The most valuable tests cover error cases: invalid input, missing resources, external service failures, and timeout scenarios. Happy path tests are necessary but insufficient.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.