Search Topics
Search across all FastAPI topics
Testing
TestingTests that pass locally but fail in CI aren't tests — they're coincidences. Let's write tests that actually prove your API works.
Your tests pass locally. CI runs them in a different order. 3 tests fail. They were depending on state from previous tests — a test database that wasn't cleaned up between runs.
# Locally (tests run in file order):
$ pytest tests/ -v
test_create_user PASSED
test_get_users PASSED (finds the user created above)
test_delete_user PASSED
# CI (random order with pytest-randomly):
$ pytest tests/ -v -p randomly
test_get_users FAILED AssertionError: assert [] == [{"id": 1, "name": "Alice"}]
test_delete_user FAILED 404: User not found
test_create_user PASSED
# Tests 2 and 3 depended on test 1 running first.Question
Ever had a test suite where some tests randomly fail? The problem isn't randomness — it's hidden dependencies. test_get_users only passed because test_create_user ran first and left a user in the database. Swap the order, and it falls apart. The fix: every test sets up its own data and cleans up after itself.
How FastAPI testing works
TestClient wraps your FastAPI app and simulates HTTP requests without starting a real server. No ports, no network — just direct Python function calls that behave exactly like real requests.
TestClient
Wraps your app
Fake HTTP
client.get('/users')
Your Endpoint
Runs normally
Response
Status + JSON
Core Concept
What you just learned
TestClient simulates HTTP without a running server — fast and reliable
Each test should be independent — set up its own data, clean up after
Tests that depend on execution order aren't tests, they're time bombs
Your first test
TestClient wraps your FastAPI app and lets you make requests as if you were a real client. Install it with pip install httpx (required for TestClient).
# pip install httpx pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
# Create a test client — no server needed
client = TestClient(app)
def test_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
# That's it. No server startup, no port conflicts.See It Run
Watch pytest execute tests against your FastAPI app. Try different scenarios to see how pass, fail, and validation results look.
Testing GET endpoints
Test query parameters, path parameters, and response shapes. Notice how you test both the happy path and edge cases — that's what catches bugs before production does.
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
items_db = [
{"id": 1, "name": "Foo"},
{"id": 2, "name": "Bar"},
]
@app.get("/items")
async def list_items(skip: int = 0, limit: int = 10):
return items_db[skip : skip + limit]
@app.get("/items/{item_id}")
async def get_item(item_id: int):
for item in items_db:
if item["id"] == item_id:
return item
return {"error": "Not found"}
client = TestClient(app)
def test_list_items():
response = client.get("/items")
assert response.status_code == 200
assert len(response.json()) == 2 # Both items returned
def test_list_items_with_params():
# Test that skip and limit actually work
response = client.get("/items?skip=1&limit=1")
assert response.status_code == 200
assert response.json() == [{"id": 2, "name": "Bar"}]
def test_get_item():
response = client.get("/items/1")
assert response.status_code == 200
assert response.json()["name"] == "Foo"GET Testing
What you just learned
Query parameters go in the URL string: client.get('/items?skip=1')
Path parameters go in the URL path: client.get('/items/1')
response.json() gives you the parsed dict — assert on it directly
Testing POST with JSON
Send JSON request bodies and verify the response. The most important test here isn't the happy path — it's the validation test. You want to make sure invalid data gets rejected with a 422, not silently accepted.
from fastapi import FastAPI
from pydantic import BaseModel
from fastapi.testclient import TestClient
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.post("/items", status_code=201)
async def create_item(item: Item):
return {"id": 1, **item.model_dump()}
client = TestClient(app)
def test_create_item():
response = client.post(
"/items",
json={"name": "Widget", "price": 9.99}, # json= for body
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Widget"
assert data["price"] == 9.99
assert "id" in data # Database should generate this
def test_create_item_invalid():
# Missing required field — should be rejected
response = client.post(
"/items",
json={"name": "Widget"}, # No price!
)
assert response.status_code == 422 # Pydantic validation errorTesting authenticated endpoints
Pass headers and tokens to test protected routes. You should test three cases: valid token, no token, and invalid token. Each should return a different status code.
from fastapi import FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/protected")
async def protected_route(authorization: str = Header()):
if authorization != "Bearer valid-token":
raise HTTPException(status_code=401, detail="Invalid token")
return {"message": "Access granted"}
client = TestClient(app)
def test_protected_with_valid_token():
response = client.get(
"/protected",
headers={"Authorization": "Bearer valid-token"},
)
assert response.status_code == 200
assert response.json()["message"] == "Access granted"
def test_protected_without_token():
response = client.get("/protected")
assert response.status_code == 422 # Missing required header
def test_protected_with_invalid_token():
response = client.get(
"/protected",
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401 # UnauthorizedWatch out
For dependency-based auth, you can override the auth dependency with a fake using dependency_overrides. But here's the trap: if you set an override in one test and forget to clear it, the next test inherits it. Always clean up overrides after each test.
Auth Testing
What you just learned
Pass headers via the headers= parameter: client.get('/path', headers={...})
Test three auth scenarios: valid token, missing token, invalid token
dependency_overrides lets you swap out auth for testing — but clean up after
Async test client: when you need it
For async endpoints that use async def, you can use httpx.AsyncClient with pytest-asyncio for truly async tests. Most of the time TestClient works fine — but if you're testing async database calls or async dependencies, you need the real async flow.
# pip install httpx pytest-asyncio
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import FastAPI
app = FastAPI()
@app.get("/async-items")
async def list_items():
# In real code, this might await a database call
return [{"id": 1, "name": "Async Item"}]
@pytest.mark.anyio
async def test_async_list_items():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/async-items")
assert response.status_code == 200
assert response.json()[0]["name"] == "Async Item"Go Deeper: The leaking override
dependency_overrides not cleaned up
You override get_db in one test to use a test database. You forget to clear the override. The next test file uses the test database too — and finds stale data from the previous test.
# test_users.py
def test_create_user():
# Override to use test DB
app.dependency_overrides[get_db] = get_test_db
response = client.post("/users", json={"name": "Alice"})
assert response.status_code == 201
# Forgot to clear the override!
# test_items.py (runs after test_users.py)
def test_list_items():
# This test has NOTHING to do with users
# But it's still using get_test_db from the override above!
response = client.get("/items")
# Unexpected behavior because the DB context is wrongThink about it...
If you use dependency_overrides in one test function and forget to clear them, what happens to the next test?
Hint: Think about what dependency_overrides actually is — it's just a Python dict on the app object.
Key Points
TestClient
Simulate HTTP requests without running a server
response.json()
Parse and assert on JSON response bodies directly
Status Codes
Always assert status_code to catch unexpected errors
422 Validation
Test invalid inputs to verify Pydantic validation works
Headers
Pass auth tokens and custom headers in test requests
AsyncClient
Use httpx.AsyncClient for truly async test execution