Module 3: Integration and E2E Tests
API Testing with FastAPI TestClient
API Testing with FastAPI TestClient
Capsule overview
In the previous capsule you understood the test pyramid and the trade-offs between levels. Now it's time to get practical: how do you test your FastAPI endpoints without spinning up a real server? How do you verify that a GET /items returns 200 with a list, that a POST /items creates the resource and returns 201, or that a protected endpoint rejects requests without a token?
The FastAPI TestClient is the standard tool for API integration testing: it wraps your application, sends real HTTP requests (in process, no network), and returns responses you can check with normal assertions. This capsule teaches you how to set it up, test GET/POST/PUT/DELETE, handle authentication, use fixtures for the client, and how to ask Claude Code to generate integration tests for your endpoints.
By the end you'll have a clear set of patterns to apply in the module project: integration tests for all the CRUD endpoints.
What Is Integration Testing for APIs?
Testing HTTP as a real client
An API integration test verifies that endpoints work end to end from an HTTP client's perspective. You're not testing isolated functions — you're testing the full chain: HTTP request → router → handler → business logic → HTTP response.
That means verifying:
- Status codes: 200, 201, 204, 400, 401, 404, 422...
- Response body: JSON structure, expected fields, correct values
- Headers:
Content-Type, custom headers, cookies - Interactions between components: that the router calls the right handler, that Pydantic validation rejects invalid payloads
Why unit tests aren't enough
A unit test can verify that calculate_total(items) returns 42. But it doesn't verify that the POST /orders endpoint receives the correct JSON, applies that function, and returns {"total": 42} with status 201. That router → handler → logic integration is what integration tests cover.
Context: Example FastAPI App
Before moving on, here's a simple FastAPI app you'll use as a reference in every example. It's an in-memory item CRUD.
# main.py
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel, Field
from typing import Optional
app = FastAPI(title="Items API")
# In-memory storage to keep things simple
items_db: dict[int, dict] = {}
next_id = 1
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
price: float = Field(..., ge=0, le=999999.99)
description: Optional[str] = None
class ItemResponse(BaseModel):
id: int
name: str
price: float
description: Optional[str] = None
@app.get("/items", response_model=list[ItemResponse])
def list_items():
"""List all items."""
return [ItemResponse(**item) for item in items_db.values()]
@app.get("/items/{item_id}", response_model=ItemResponse)
def get_item(item_id: int):
"""Get an item by ID."""
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
return ItemResponse(**items_db[item_id])
@app.post("/items", response_model=ItemResponse, status_code=201)
def create_item(item: ItemCreate):
"""Create a new item."""
global next_id
item_data = {"id": next_id, **item.model_dump()}
items_db[next_id] = item_data
next_id += 1
return ItemResponse(**item_data)
@app.put("/items/{item_id}", response_model=ItemResponse)
def update_item(item_id: int, item: ItemCreate):
"""Update an existing item."""
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
items_db[item_id].update(item.model_dump())
return ItemResponse(**items_db[item_id])
@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int):
"""Delete an item."""
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
del items_db[item_id]
# Protected endpoint for auth examples
FAKE_TOKEN = "secret-token-123"
def verify_token(authorization: Optional[str] = None):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid token")
token = authorization.split(" ")[1]
if token != FAKE_TOKEN:
raise HTTPException(status_code=401, detail="Invalid token")
return token
@app.get("/admin/users")
def list_admin_users(auth: str = Depends(verify_token)):
"""List users (protected)."""
return {"users": ["admin@example.com"], "token_used": auth}
With this app you'll have endpoints to list, get, create, update and delete items, plus a protected endpoint to practice authentication.
Setting Up the FastAPI TestClient
Basic configuration
FastAPI's TestClient comes from starlette.testclient (FastAPI is built on top of Starlette). You use it like this:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
TestClient(app) takes your FastAPI instance and creates a client that sends HTTP requests against that app.
Important characteristics
- No server is started: Requests run in process. There's no network, no port. It's very fast.
- Synchronous interface: Even though FastAPI is async, TestClient exposes
.get(),.post(), etc. synchronously. Internally it useshttpxand takes care of running the app in an event loop. - Real HTTP requests: The client sends requests just like curl or a browser would. Pydantic validates, middlewares run, the whole FastAPI stack works.
Minimal structure of a test file
# tests/integration/test_items_api.py
import pytest
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_list_items_returns_200():
response = client.get("/items")
assert response.status_code == 200
assert isinstance(response.json(), list)
You run it with pytest tests/integration/test_items_api.py -v.
Testing GET Endpoints
Listing all items
def test_get_items_returns_200():
response = client.get("/items")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_get_items_returns_empty_list_initially():
response = client.get("/items")
assert response.status_code == 200
data = response.json()
assert data == []
⚠️ In an app with a DB shared between tests, the state may not be "initial". That's why you use fixtures to isolate (see below). In the in-memory app, if you don't create items, the list will be empty.
Getting an item by ID
def test_get_item_by_id():
# ARRANGE: create an item first
create_response = client.post("/items", json={"name": "Test Item", "price": 29.99})
assert create_response.status_code == 201
item = create_response.json()
item_id = item["id"]
# ACT: get the item
response = client.get(f"/items/{item_id}")
# ASSERT
assert response.status_code == 200
data = response.json()
assert data["id"] == item_id
assert data["name"] == "Test Item"
assert data["price"] == 29.99
def test_get_nonexistent_item_returns_404():
response = client.get("/items/99999")
assert response.status_code == 404
data = response.json()
assert "detail" in data
assert "not found" in data["detail"].lower()
Verifying headers
def test_get_items_returns_json_content_type():
response = client.get("/items")
assert response.status_code == 200
assert "application/json" in response.headers.get("content-type", "")
Verifying the JSON structure
Beyond the status code, it's worth verifying that the body has the expected shape:
def test_get_item_returns_expected_schema():
create_resp = client.post("/items", json={"name": "Schema Test", "price": 1.0})
item_id = create_resp.json()["id"]
response = client.get(f"/items/{item_id}")
data = response.json()
# Verify that the ItemResponse fields exist
assert "id" in data
assert "name" in data
assert "price" in data
assert isinstance(data["id"], int)
assert isinstance(data["name"], str)
assert isinstance(data["price"], (int, float))
Testing POST Endpoints
Successful item creation
def test_create_item_returns_201():
payload = {"name": "New Item", "price": 49.99}
response = client.post("/items", json=payload)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Item"
assert data["price"] == 49.99
assert "id" in data
assert isinstance(data["id"], int)
def test_create_item_with_description():
payload = {"name": "Item with description", "price": 10.0, "description": "A test item"}
response = client.post("/items", json=payload)
assert response.status_code == 201
data = response.json()
assert data["description"] == "A test item"
Invalid payload → 422
Pydantic validates automatically. If you send data that doesn't satisfy the schema, FastAPI returns 422 Unprocessable Entity.
def test_create_item_empty_name_returns_422():
response = client.post("/items", json={"name": ""})
assert response.status_code == 422
def test_create_item_missing_required_field_returns_422():
response = client.post("/items", json={"name": "Name only"}) # price is missing
assert response.status_code == 422
def test_create_item_negative_price_returns_422():
response = client.post("/items", json={"name": "Item", "price": -10})
assert response.status_code == 422
def test_create_item_invalid_json_returns_422():
response = client.post("/items", json={"name": 123}) # name must be a str
assert response.status_code == 422
Inspecting the 422 detail
When a validation test fails, FastAPI's 422 body includes the Pydantic errors:
def test_create_item_422_detail_structure():
response = client.post("/items", json={"name": ""})
assert response.status_code == 422
data = response.json()
# FastAPI returns {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}
assert "detail" in data
assert len(data["detail"]) > 0
assert any("name" in str(err.get("loc", [])) for err in data["detail"])
That helps you write more specific assertions or debug which validation rule failed.
Testing PUT and DELETE
PUT: updating an item
def test_update_item_returns_200():
# ARRANGE
create_resp = client.post("/items", json={"name": "Original", "price": 5.0})
item_id = create_resp.json()["id"]
# ACT
update_payload = {"name": "Updated", "price": 15.0}
response = client.put(f"/items/{item_id}", json=update_payload)
# ASSERT
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated"
assert data["price"] == 15.0
assert data["id"] == item_id
def test_update_nonexistent_item_returns_404():
response = client.put("/items/99999", json={"name": "X", "price": 1.0})
assert response.status_code == 404
DELETE: deleting and verifying
def test_delete_item_returns_204():
create_resp = client.post("/items", json={"name": "To Delete", "price": 1.0})
item_id = create_resp.json()["id"]
response = client.delete(f"/items/{item_id}")
assert response.status_code == 204
assert response.content == b""
def test_delete_item_then_get_returns_404():
create_resp = client.post("/items", json={"name": "To Delete", "price": 1.0})
item_id = create_resp.json()["id"]
client.delete(f"/items/{item_id}")
get_response = client.get(f"/items/{item_id}")
assert get_response.status_code == 404
def test_delete_nonexistent_item_returns_404():
response = client.delete("/items/99999")
assert response.status_code == 404
Testing with Authentication
Protected endpoint without a token → 401
def test_protected_endpoint_without_token_returns_401():
response = client.get("/admin/users")
assert response.status_code == 401
def test_protected_endpoint_with_invalid_token_returns_401():
response = client.get("/admin/users", headers={"Authorization": "Bearer wrong-token"})
assert response.status_code == 401
With a valid token → 200
def test_protected_endpoint_with_valid_token():
response = client.get(
"/admin/users",
headers={"Authorization": "Bearer secret-token-123"}
)
assert response.status_code == 200
data = response.json()
assert "users" in data
assert "admin@example.com" in data["users"]
A reusable token fixture
@pytest.fixture
def auth_headers():
return {"Authorization": "Bearer secret-token-123"}
def test_admin_with_fixture(auth_headers):
response = client.get("/admin/users", headers=auth_headers)
assert response.status_code == 200
Fixtures for TestClient
The client fixture
Instead of a global client, you use a fixture so each test gets a fresh client (useful if the app has state or connections):
@pytest.fixture
def client():
return TestClient(app)
def test_list_items_with_fixture(client):
response = client.get("/items")
assert response.status_code == 200
A fixture that creates data (a pre-created item)
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def created_item(client):
"""Creates an item and returns it for use in tests."""
response = client.post("/items", json={"name": "Fixture Item", "price": 10.0})
assert response.status_code == 201
return response.json()
def test_get_item_using_fixture(client, created_item):
item_id = created_item["id"]
response = client.get(f"/items/{item_id}")
assert response.status_code == 200
assert response.json()["name"] == "Fixture Item"
def test_update_item_using_fixture(client, created_item):
item_id = created_item["id"]
response = client.put(f"/items/{item_id}", json={"name": "Updated", "price": 20.0})
assert response.status_code == 200
assert response.json()["name"] == "Updated"
Fixture order
created_item depends on client. pytest resolves the order automatically: it runs client first, then created_item, and finally injects both into the test.
Claude Code: Prompt for Integration Tests
Generic prompt
When you want Claude Code to generate integration tests for your FastAPI endpoints, an effective prompt would be:
Generate integration tests for these FastAPI endpoints using TestClient.
Context:
- App in main.py with an item CRUD
- Endpoints: GET /items, GET /items/{id}, POST /items, PUT /items/{id}, DELETE /items/{id}
Requirements:
1. Use from fastapi.testclient import TestClient and TestClient(app)
2. For each endpoint: happy path tests (200/201/204) and error tests (404, 422, 401 where applicable)
3. Use arrange-act-assert
4. Verify status_code, response.json(), and the expected structure
5. For POST/PUT verify that an invalid payload returns 422
6. Use fixtures for the client and for pre-created items when needed
Organize it in tests/integration/test_items_api.py
A more specific prompt
Generate integration tests for the items API in main.py.
Required coverage:
- GET /items: 200, empty list or with items
- GET /items/{id}: 200 when it exists, 404 when it doesn't
- POST /items: 201 with a valid payload, 422 with an empty name, a negative price, or missing fields
- PUT /items/{id}: 200 when updating, 404 if it doesn't exist
- DELETE /items/{id}: 204 when deleting, 404 if it doesn't exist
Use pytest fixtures:
- client: TestClient(app)
- created_item: creates an item via POST and returns the JSON for tests that need an existing item
Format: tests/integration/test_items_api.py
What to review in what Claude Code generates
- ✅ Each test verifies a single thing (one status, one field)
- ✅ Error tests (404, 422) are included
- ✅
json=is used in POST/PUT, notdata= - ✅ Tests don't depend on execution order (isolated state or fixtures)
Comparison: Unit vs Integration for APIs
| Aspect | Unit test | Integration test |
|---|---|---|
| What it tests | An isolated function (e.g. calculate_total) | A complete endpoint (GET/POST/...) |
| Dependencies | Mocked or minimal fixtures | Real app, TestClient |
| Speed | Very fast | Fast (no network) |
| Confidence | Internal logic is correct | Request → response is correct |
| When it fails | A bug in the function | A bug in the router, validation, serialization |
Unit tests verify the logic. Integration tests verify that the API responds properly to HTTP requests.
Project Connection
In this module's project (capsule 06) you'll build a complete test pyramid for an item REST API. The integration tests you write here — or that you generate with Claude Code — are exactly what you need in tests/integration/.
Practical criteria:
- ✅ Integration tests for all the CRUD endpoints: GET list, GET by id, POST, PUT, DELETE
- ✅ Verification of status codes (200, 201, 204, 404, 422)
- ✅ Verification of the response body (fields, types)
- ✅ Invalid payload tests (422)
- ✅ Use of fixtures for
clientandcreated_item - ✅ Organization in
tests/integration/test_items_api.py
The next capsule (04) covers database testing with fixtures. When your API uses a real DB, you'll need fixtures that set up a test DB and clean it after each test. For now, the in-memory app and the TestClient are enough to master the patterns.
Troubleshooting
Problem 1: response.json() fails with "Expecting value"
Cause: The response body isn't valid JSON (for example, it's empty on a 204, or it's an HTML error page).
Solution: Check the status before calling .json(). For 204 No Content, there is no body:
def test_delete_returns_204():
response = client.delete("/items/1")
assert response.status_code == 204
# Don't call response.json() — the body is empty
assert response.content == b""
If you expect JSON and it fails, print response.text to see what the server returned.
Problem 2: Tests pass in isolation but fail when running the full suite
Cause: Shared state between tests. The in-memory app (items_db, next_id) persists between tests. One test creates items that affect others.
Solution: Reset the state at the start of each test or use a fixture that cleans up:
# In conftest.py or at the top of the module
from main import items_db, next_id
# Better: refactor the app to inject the storage (dependency injection pattern)
In the example app, items_db and next_id are globals. For isolated tests, you'd have to clear items_db and reset next_id in an autouse=True fixture, or use an app that accepts an injected storage. For learning purposes, running tests in random order (pytest --random-order) helps you detect hidden dependencies.
Problem 3: A 422 on POST when you think the payload is correct
Cause: Pydantic expects specific types. price must be a float, not a string. Required fields are missing.
Solution: Use json= with the correct Python types:
# ❌ WRONG
client.post("/items", json={"name": "X", "price": "10"}) # price is a str
# ✅ RIGHT
client.post("/items", json={"name": "X", "price": 10.0})
Check the model's schema (ItemCreate) and the 422 detail in response.json() — FastAPI returns the validation errors.
Problem 4: TestClient can't find the app or there's an ImportError
Cause: A circular import, or the app isn't being imported correctly.
Solution: Import the app after it's configured. If main.py has code that runs on import (e.g. a DB connection), consider creating the app in a function or delaying the creation of the TestClient:
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
# Import the app after the environment is ready
from main import app
@pytest.fixture
def client():
return TestClient(app)
Problem 5: An async endpoint and TestClient — does it work?
Cause: Doubts about whether TestClient supports async endpoints.
Solution: Yes. TestClient uses httpx and runs the async app internally. You don't need to configure anything special. async def endpoints work just like def ones with TestClient.
Exercises
Exercise 1: Basic GET test (Easy)
Write a test that verifies GET /items returns status 200 and that the body is a list (it may be empty).
See solution
def test_get_items_returns_200_and_list():
response = client.get("/items")
assert response.status_code == 200
assert isinstance(response.json(), list)
Exercise 2: POST test with validation (Easy)
Write two tests: one that verifies creating an item with a valid payload returns 201 and contains id, name and price; another that verifies sending a negative price returns 422.
See solution
def test_create_item_valid_returns_201_with_id():
payload = {"name": "Valid item", "price": 19.99}
response = client.post("/items", json=payload)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert data["name"] == "Valid item"
assert data["price"] == 19.99
def test_create_item_negative_price_returns_422():
payload = {"name": "Item", "price": -5.0}
response = client.post("/items", json=payload)
assert response.status_code == 422
Exercise 3: CREATE → GET flow (Medium)
Write a test that creates an item with POST, gets the ID from the response, and then does a GET of that item verifying that the data matches.
See solution
def test_create_then_get_returns_same_data():
# ARRANGE & ACT: create
payload = {"name": "Flow item", "price": 33.0}
create_resp = client.post("/items", json=payload)
assert create_resp.status_code == 201
created = create_resp.json()
item_id = created["id"]
# ACT: get
get_resp = client.get(f"/items/{item_id}")
# ASSERT
assert get_resp.status_code == 200
retrieved = get_resp.json()
assert retrieved["id"] == item_id
assert retrieved["name"] == payload["name"]
assert retrieved["price"] == payload["price"]
Exercise 4: The created_item fixture (Medium)
Create a created_item fixture that POSTs an item and returns the JSON. Use that fixture in two tests: one that GETs the item and another that DELETEs it and verifies a 204.
See solution
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def created_item(client):
response = client.post("/items", json={"name": "Fixture item", "price": 7.5})
assert response.status_code == 201
return response.json()
def test_get_created_item(client, created_item):
item_id = created_item["id"]
response = client.get(f"/items/{item_id}")
assert response.status_code == 200
assert response.json()["name"] == "Fixture item"
def test_delete_created_item(client, created_item):
item_id = created_item["id"]
response = client.delete(f"/items/{item_id}")
assert response.status_code == 204
Exercise 5: Test the protected endpoint (Medium)
Write three tests for /admin/users: without an Authorization header (401), with an invalid token (401), and with the valid token Bearer secret-token-123 (200, with "users" in the body).
See solution
def test_admin_without_token_returns_401():
response = client.get("/admin/users")
assert response.status_code == 401
def test_admin_with_invalid_token_returns_401():
response = client.get("/admin/users", headers={"Authorization": "Bearer bad-token"})
assert response.status_code == 401
def test_admin_with_valid_token_returns_200():
response = client.get(
"/admin/users",
headers={"Authorization": "Bearer secret-token-123"}
)
assert response.status_code == 200
data = response.json()
assert "users" in data
Exercise 6: Prompt for Claude Code (Medium)
Write a prompt that asks Claude Code to generate integration tests for an API with the endpoints GET /products and POST /products (create a product with name, price). Include: use of TestClient, fixtures for the client, happy path and 422 for an invalid payload.
See solution
Generate integration tests for a FastAPI API with these endpoints:
- GET /products: returns a list of products
- POST /products: creates a product with body {"name": str, "price": float}
Requirements:
1. Use the FastAPI TestClient
2. A `client` fixture that returns TestClient(app)
3. For GET: a test that returns 200 and a list (empty or with data)
4. For POST: a 201 test with a valid payload; a 422 test with an empty name; a 422 test with a negative price
5. Use arrange-act-assert
6. File: tests/integration/test_products_api.py
Summary
- ✅ API integration testing verifies HTTP endpoints like a real client: status codes, body, headers.
- ✅ The FastAPI TestClient wraps the app, sends requests in process (no server) and returns verifiable responses.
- ✅ GET tests: 200, body structure, 404 for a nonexistent resource.
- ✅ POST tests: 201 with a valid payload, 422 when validation fails (missing fields, wrong types).
- ✅ PUT and DELETE tests: 200/204 on success, 404 when the resource doesn't exist.
- ✅ Protected endpoints: 401 without a token or with an invalid one, 200 with a valid token in the Authorization header.
- ✅ The
clientandcreated_itemfixtures avoid repetition and prepare data for tests. - ✅ Claude Code can generate integration tests with prompts that specify endpoints, coverage and the use of fixtures.
- ✅ The next capsule covers database testing with fixtures — test DB setup and teardown.
Next capsule: Database testing with fixtures — setting up a test database, seed data, and isolation between tests.
Additional Resources
- FastAPI: Testing - Official documentation on testing with TestClient
- Starlette TestClient - The base implementation of the TestClient
- httpx Documentation - The HTTP client that TestClient uses internally
- pytest Fixtures - Fixtures for the client and data
- Test Pyramid (Martin Fowler) - Testing level strategy
Module 3, Capsule 03 — Testing with Claude Code Guide