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 uses httpx and 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, not data=
  • ✅ Tests don't depend on execution order (isolated state or fixtures)

Comparison: Unit vs Integration for APIs

AspectUnit testIntegration test
What it testsAn isolated function (e.g. calculate_total)A complete endpoint (GET/POST/...)
DependenciesMocked or minimal fixturesReal app, TestClient
SpeedVery fastFast (no network)
ConfidenceInternal logic is correctRequest → response is correct
When it failsA bug in the functionA 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 client and created_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 client and created_item fixtures 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

  1. FastAPI: Testing - Official documentation on testing with TestClient
  2. Starlette TestClient - The base implementation of the TestClient
  3. httpx Documentation - The HTTP client that TestClient uses internally
  4. pytest Fixtures - Fixtures for the client and data
  5. Test Pyramid (Martin Fowler) - Testing level strategy

Module 3, Capsule 03 — Testing with Claude Code Guide