Module 3: Integration and E2E Tests
E2E Testing: Complete API Flows
E2E Testing: Complete API Flows
Capsule overview
In the previous capsules you tested endpoints one by one: POST returns 201, GET returns 404 when it doesn't exist. Those are integration tests — they verify interfaces. But a real client of your API doesn't call a single endpoint. It calls several in sequence: create resource → get it → update it → delete it. If the complete flow fails at some link, the individual integration tests can be green while the user has a broken experience.
This capsule covers E2E testing for APIs: validating complete flows exactly as a real HTTP client would use them. We're not talking about Selenium or Playwright — here E2E means backend end-to-end: the full chain request → routing → handler → logic → database → response, but crossing multiple requests that form a user story. By the end you'll be able to write E2E tests with TestClient, organize them properly, and avoid the flakiness trap.
What E2E Means for APIs
Testing the complete flow as a real client
An API E2E test simulates what a consumer of your API does when it runs a complete operation:
Real client (Postman, frontend, another API):
1. POST /users → Creates a user
2. POST /login → Gets a token
3. POST /orders → Creates an order (with the token)
4. GET /orders/1 → Reads the order
5. PATCH /orders/1 → Updates the status
6. GET /orders/1 → Verifies the change
An integration test verifies only one of those steps in isolation: "POST /orders returns 201 with the correct body". An E2E test verifies that the whole sequence works from start to finish. If at step 4 the order doesn't exist (because of a bug in step 3), the POST integration test could be green while the real flow breaks.
The complete chain: request → response
In an API E2E test you verify:
- The HTTP request arrives correctly at the server
- Routing directs it to the right function
- The handler processes the request
- The business logic runs
- The database persists or reads data
- The response comes back in the expected format
And you do it for several chained requests. Each request can depend on the result of the previous one (for example, the id returned by a POST is used in the following GET).
It's not browser testing
In this guide, E2E = API flows, not UI testing. You don't use Selenium, Playwright or headless browsers. The client is FastAPI's TestClient or httpx — pure HTTP against your backend. The frontend isn't involved.
E2E vs Integration: The Key Difference
Integration: one endpoint in isolation
Typical integration test:
- POST /items with valid json → assert 201
- Verify that the body has id, name, price
- (Optional) Verify that it exists in the DB
A single request. A single endpoint. The test passes or fails based on that point of contact.
E2E: a flow across multiple endpoints
Typical E2E test:
1. POST /items → 201, save the id
2. GET /items/{id} → 200, same name
3. PUT /items/{id} → 200, updated name
4. DELETE /items/{id} → 200
5. GET /items/{id} → 404 (resource deleted)
Several requests in sequence. Each one depends on the previous one. If any of them fails, the test fails — and you know there's a problem in the complete flow, not just in one endpoint.
Comparison table
| Aspect | Integration | E2E |
|---|---|---|
| Requests per test | 1 (sometimes 2 for setup) | 3-10+ |
| What it verifies | The endpoint's interface | The user workflow |
| Speed | Fast | Slower |
| Confidence | The endpoint works | The complete flow works |
| Maintenance | Low | Higher |
| Flakiness | Low | High if you don't take care of the setup |
Practical rule
- Integration verifies that the pieces fit together (endpoint ↔ DB, endpoint ↔ service).
- E2E verifies that the business flow works end to end.
Writing E2E Tests with TestClient
Minimal setup
You need the FastAPI app and a client. If you use a database, each test must start from a clean state (or use transactions/an in-memory DB).
# conftest.py (or in the test file)
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
return TestClient(app)
Example: a complete CRUD lifecycle
# tests/e2e/test_item_lifecycle.py
import pytest
from fastapi.testclient import TestClient
from main import app, items_db # assuming an in-memory store or a test DB
client = TestClient(app)
def setup_function():
"""Cleans the state before each test (avoids flakiness)."""
items_db.clear()
class TestItemLifecycle:
"""E2E: Complete CRUD lifecycle for items."""
def test_full_item_crud_lifecycle(self):
# CREATE
create_response = client.post(
"/items",
json={"name": "E2E Item", "price": 42.0}
)
assert create_response.status_code == 201
data = create_response.json()
item_id = data["id"]
# READ
get_response = client.get(f"/items/{item_id}")
assert get_response.status_code == 200
assert get_response.json()["name"] == "E2E Item"
# UPDATE
update_response = client.put(
f"/items/{item_id}",
json={"name": "Updated", "price": 99.0}
)
assert update_response.status_code == 200
assert update_response.json()["name"] == "Updated"
# DELETE
delete_response = client.delete(f"/items/{item_id}")
assert delete_response.status_code == 200 # or 204 depending on your API
# VERIFY DELETED
verify_response = client.get(f"/items/{item_id}")
assert verify_response.status_code == 404
A single test that validates the complete flow. If it fails, you know something in the CRUD chain is broken.
The example app's code (complete)
# main.py — a minimal API so the tests work
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
items_db: dict[int, dict] = {}
_id_counter = 0
class ItemCreate(BaseModel):
name: str
price: float
class ItemUpdate(BaseModel):
name: str | None = None
price: float | None = None
class ItemResponse(BaseModel):
id: int
name: str
price: float
@app.post("/items", response_model=ItemResponse)
def create_item(item: ItemCreate):
global _id_counter
_id_counter += 1
obj = {"id": _id_counter, "name": item.name, "price": item.price}
items_db[_id_counter] = obj
return obj
@app.get("/items/{item_id}", response_model=ItemResponse)
def get_item(item_id: int):
if item_id not in items_db:
raise HTTPException(404, "Item not found")
return items_db[item_id]
@app.put("/items/{item_id}", response_model=ItemResponse)
def update_item(item_id: int, item: ItemUpdate):
if item_id not in items_db:
raise HTTPException(404, "Item not found")
obj = items_db[item_id]
if item.name is not None:
obj["name"] = item.name
if item.price is not None:
obj["price"] = item.price
return obj
@app.delete("/items/{item_id}")
def delete_item(item_id: int):
if item_id not in items_db:
raise HTTPException(404, "Item not found")
del items_db[item_id]
return {"status": "deleted"}
Common E2E Patterns
1. Register → login → protected resource
def test_user_registration_login_and_protected_access(self, client):
# 1. Register
reg = client.post("/auth/register", json={
"email": "e2e@test.com",
"password": "secure123"
})
assert reg.status_code == 201
# 2. Login
login = client.post("/auth/login", json={
"email": "e2e@test.com",
"password": "secure123"
})
assert login.status_code == 200
token = login.json()["access_token"]
# 3. Protected access
headers = {"Authorization": f"Bearer {token}"}
resp = client.get("/me", headers=headers)
assert resp.status_code == 200
assert resp.json()["email"] == "e2e@test.com"
2. Parent → child → parent with children
def test_create_parent_then_child_then_fetch_parent_with_children(self, client):
parent = client.post("/categories", json={"name": "Electronics"})
parent_id = parent.json()["id"]
child = client.post("/categories", json={
"name": "Phones",
"parent_id": parent_id
})
assert child.status_code == 201
parent_with_children = client.get(f"/categories/{parent_id}?include_children=true")
assert len(parent_with_children.json()["children"]) == 1
3. Idempotency
def test_double_create_with_same_idempotency_key_returns_same_resource(self, client):
key = "idem-123"
r1 = client.post("/orders", json={"items": [1, 2]}, headers={"Idempotency-Key": key})
r2 = client.post("/orders", json={"items": [1, 2]}, headers={"Idempotency-Key": key})
assert r1.status_code == 201
assert r2.status_code == 200 # or 201 if it's an idempotent create
assert r1.json()["id"] == r2.json()["id"]
4. Error flows
def test_create_invalid_then_fix_then_succeed(self, client):
# Invalid attempt
r1 = client.post("/items", json={"name": "", "price": -1})
assert r1.status_code == 422
# Correct one
r2 = client.post("/items", json={"name": "Valid", "price": 10.0})
assert r2.status_code == 201
# Retrieve a nonexistent one
r3 = client.get("/items/99999")
assert r3.status_code == 404
When the Cost of E2E Is Worth It
Where to invest
- Critical business paths: checkout, registration, payment, onboarding.
- Flows that cross several components: auth + CRUD + notifications.
- Regression protection: bugs that already happened and that you don't want back.
- Contracts with external clients: if another API depends on your complete flow.
Where not to invest
- Every variation of every endpoint: those are integration tests.
- Edge cases of pure logic: those are unit tests.
- Unlikely hypothetical scenarios: the ROI doesn't pay off.
Practical rule
Having 2-5 E2E flows per project is usually enough. More than that and the suite becomes slow and fragile. Integration tests cover the rest.
Checklist: E2E or integration?
Does the behavior you want to verify require multiple chained requests?
→ Yes: E2E
→ No: Integration
Is the value in the complete flow working (register → login → resource)?
→ Yes: E2E
→ No: Integration
Do you only need to verify that an endpoint responds correctly?
→ Integration
Do you want regression protection on a critical business flow?
→ E2E
Organizing E2E Tests
Directory structure
tests/
├── unit/
├── integration/
└── e2e/
├── test_item_lifecycle.py
├── test_user_auth_flow.py
└── test_order_checkout_flow.py
Naming conventions
- File: one file per flow or user story.
- Tests: descriptive
test_*names that explain the flow.
Examples:
def test_full_item_crud_lifecycle(self): ...
def test_user_registration_flow(self): ...
def test_order_checkout_flow(self): ...
def test_create_parent_then_child_then_fetch_with_children(self): ...
Running E2E separately (pytest markers)
E2E tests are slower. You can run them only when needed:
# tests/e2e/test_item_lifecycle.py
import pytest
@pytest.mark.e2e
def test_full_crud_lifecycle(client):
...
# Only unit and integration (fast)
pytest -m "not e2e"
# Only E2E
pytest -m e2e
# Everything
pytest
In pytest.ini or pyproject.toml:
[pytest]
markers =
e2e: End-to-end tests (slower, full flows)
Claude Code for Generating E2E Tests
Base prompt
Generate E2E tests that validate the complete flow of [flow description].
Each test must simulate a real user using the API from start to finish.
Use the FastAPI TestClient. Don't use Selenium or Playwright — HTTP only.
Include setup to clean the state between tests (e.g. clear the DB or a fixture).
A concrete example
Generate E2E tests for the item CRUD flow in my FastAPI API.
Flow: POST create item → GET by id → PUT update → DELETE → GET verify 404.
Use TestClient. One test per complete flow.
Include a setup_function that clears items_db before each test.
Differences from integration
| Level | Typical prompt |
|---|---|
| Integration | "Generate integration tests for POST /items. One test per endpoint, verify the status and body." |
| E2E | "Generate an E2E test that validates the complete flow: create → read → update → delete → verify deleted." |
The E2E Trap: Flakiness
What flakiness is
Tests that sometimes pass and sometimes fail without the code changing. It's more common in E2E because more components are involved.
Usual causes
- Shared state: tests that modify the same DB or the same resource.
- Execution order: a test that depends on data created by another one.
- Timing: timeouts, delays, race conditions.
- Non-deterministic data: IDs, timestamps, randomness.
How to avoid it
- Clean state per test: a
setup_functionor fixture that empties/resets the DB. - Independent tests: each test creates its own data.
- Avoid execution order dependencies: no test depends on another.
- Isolated fixtures: each test has its own client, DB, etc.
- Avoid sleep(): use polling or mocks for time.
An anti-pattern example
# BAD: shared state between tests
items_db = [] # global
def test_create_item():
client.post("/items", json={"name": "A"}) # id=1
# If another test already created items, the id may not be 1
def test_get_item():
r = client.get("/items/1") # Assumes id=1 exists
assert r.status_code == 200 # Fails if test_create_item didn't run first
The correct pattern
def setup_function():
items_db.clear()
def test_full_lifecycle():
create = client.post("/items", json={"name": "A"})
item_id = create.json()["id"] # Use the id from the response
# ... the rest of the flow with item_id
Fixtures for E2E with a Real Database
When your API uses PostgreSQL, SQLite or another DB, the E2E tests need a clean state without affecting development data.
Option 1: In-memory DB (SQLite)
# conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
from main import app
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture(scope="function")
def client():
Base.metadata.create_all(bind=engine)
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
Base.metadata.drop_all(bind=engine)
app.dependency_overrides.clear()
Each test uses an empty in-memory DB. Fast and isolated.
Option 2: Transactions that get rolled back
from sqlalchemy.orm import Session
@pytest.fixture
def db_session():
"""Creates a transaction that gets rolled back after the test."""
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
Each test runs in a transaction that never gets committed. At the end, rollback — the DB stays the same as it was at the start.
Option 3: Cleaning tables in setup
from sqlalchemy import text
def setup_function():
with engine.connect() as conn:
conn.execute(text("DELETE FROM items"))
conn.execute(text("DELETE FROM categories"))
conn.commit()
Simpler but less isolated if there are foreign keys or triggers. Use it when you don't have transactions available.
Integration vs E2E: Side by Side
The same functionality (item CRUD), two approaches:
Integration: isolated endpoints
# tests/integration/test_items_api.py
def test_post_items_returns_201(client):
r = client.post("/items", json={"name": "Item", "price": 10.0})
assert r.status_code == 201
assert "id" in r.json()
def test_get_item_returns_200(client):
create = client.post("/items", json={"name": "X", "price": 5.0})
item_id = create.json()["id"]
r = client.get(f"/items/{item_id}")
assert r.status_code == 200
def test_put_item_returns_200(client):
create = client.post("/items", json={"name": "Y", "price": 1.0})
item_id = create.json()["id"]
r = client.put(f"/items/{item_id}", json={"name": "Z", "price": 2.0})
assert r.status_code == 200
def test_delete_item_returns_200(client):
create = client.post("/items", json={"name": "W", "price": 0.0})
item_id = create.json()["id"]
r = client.delete(f"/items/{item_id}")
assert r.status_code == 200
4 tests, each one verifying one endpoint. If PUT fails but POST and GET work, only test_put_item fails. They don't check that the complete flow is coherent.
E2E: the complete flow
# tests/e2e/test_item_lifecycle.py
def test_full_crud_lifecycle(client):
# Everything in a single test, in the real order of use
create = client.post("/items", json={"name": "E2E", "price": 42.0})
assert create.status_code == 201
item_id = create.json()["id"]
get1 = client.get(f"/items/{item_id}")
assert get1.status_code == 200
assert get1.json()["name"] == "E2E"
update = client.put(f"/items/{item_id}", json={"name": "Updated", "price": 99.0})
assert update.status_code == 200
get2 = client.get(f"/items/{item_id}")
assert get2.json()["name"] == "Updated"
delete = client.delete(f"/items/{item_id}")
assert delete.status_code == 200
get3 = client.get(f"/items/{item_id}")
assert get3.status_code == 404
1 test that validates the complete flow. If DELETE doesn't actually delete and GET keeps returning 200, this test fails. The individual integration tests could still be green.
Exercises
Exercise 1: Telling Integration and E2E apart (Easy)
Say whether each description corresponds to an Integration or E2E test:
- Verifies that POST /users returns 201 with the user in the body.
- Verifies that after registering a user, logging in and creating a post, the post appears in GET /users/me/posts.
- Verifies that GET /products/999 returns 404.
- Verifies the flow: create a category → create a product with that category → get the category with its products included.
See solution
- Integration — A single endpoint, a single request.
- E2E — A sequence: register → login → create post → GET.
- Integration — A single GET.
- E2E — A flow of multiple requests with dependencies between them.
Exercise 2: E2E for the auth flow (Medium)
You have the endpoints: POST /register, POST /login, GET /me (protected with Bearer). Write an E2E test that validates: registration → login → access to /me with the token.
See solution
def test_register_login_and_access_me(client):
# 1. Register
reg = client.post("/register", json={
"email": "e2e@test.com",
"password": "secret123"
})
assert reg.status_code == 201
# 2. Login
login = client.post("/login", json={
"email": "e2e@test.com",
"password": "secret123"
})
assert login.status_code == 200
token = login.json()["access_token"]
# 3. Access protected resource
me = client.get("/me", headers={"Authorization": f"Bearer {token}"})
assert me.status_code == 200
assert me.json()["email"] == "e2e@test.com"
Exercise 3: Setup to avoid flakiness (Medium)
An E2E test fails randomly. You suspect shared state. The test creates items and sometimes fails on assert create.json()["id"] == 1. What changes would you make?
See solution
Problem: It assumes the first item created has id == 1. If other tests or previous runs left data behind, the id may be higher.
Changes:
- Cleanup before the test:
def setup_function():
items_db.clear() # or truncate the table in a real DB
- Don't assume a fixed id:
create = client.post("/items", json={"name": "X", "price": 1.0})
item_id = create.json()["id"] # Use the returned id
# Use item_id in the rest of the test
- Isolated fixtures: Make sure each test has its own DB or a transaction that gets rolled back.
Exercise 4: Writing a complete E2E (Medium)
Implement an E2E test for a tasks API with POST /tasks, GET /tasks/{id}, PATCH /tasks/{id}, DELETE /tasks/{id}. The flow: create → read → update (mark it completed) → delete → verify 404.
See solution
def test_full_task_lifecycle(client):
# Create
create = client.post("/tasks", json={"title": "E2E task"})
assert create.status_code == 201
task_id = create.json()["id"]
# Read
get1 = client.get(f"/tasks/{task_id}")
assert get1.status_code == 200
assert get1.json()["title"] == "E2E task"
assert get1.json()["completed"] is False
# Update
patch = client.patch(f"/tasks/{task_id}", json={"completed": True})
assert patch.status_code == 200
assert patch.json()["completed"] is True
# Delete
delete = client.delete(f"/tasks/{task_id}")
assert delete.status_code in (200, 204)
# Verify gone
get2 = client.get(f"/tasks/{task_id}")
assert get2.status_code == 404
Exercise 5: Prompt for Claude Code (Easy)
You want Claude Code to generate E2E tests for the checkout flow: add items to the cart → apply a coupon → confirm the order → verify that the order exists. Write the prompt.
See solution
Generate E2E tests that validate my API's complete checkout flow.
Flow: POST /cart/items (add items) → POST /cart/coupon (apply coupon) → POST /orders (confirm) → GET /orders/{id} (verify the order was created).
Use the FastAPI TestClient. One test per complete flow.
Each test must create its own cart (or use a setup that cleans the state).
Don't use Selenium or Playwright — only HTTP against the API.
Exercise 6: E2E or Integration? (Medium)
For each scenario, say whether you'd choose E2E or Integration and why:
- a) Verifying that a change in the email validation at registration breaks the register → login flow.
- b) Verifying that PUT /users/{id} returns 400 when the email already exists.
- c) Verifying that after creating 10 orders, GET /orders returns all 10.
See solution
- a) E2E — The complete flow (register → login) is what matters. An integration test of POST /register might not cover the interdependency well.
- b) Integration — A single endpoint, a single request with an error case. You don't need to chain requests.
- c) Integration or E2E — It depends. If you only verify that GET returns what was created, it's integration (create + GET). If you verify a broader flow (create → pay → list completed orders), it would be E2E. For "create 10 and list them" integration is usually enough.
Project Connection
The module project requires a complete test pyramid for an item REST API. According to capsule 01, you must include:
- Unit tests for the business logic.
- Integration tests for each endpoint.
- At least 2 E2E tests that validate complete flows.
Examples of E2E tests that fit:
- Item CRUD flow: create → read → update → delete → verify 404.
- Flow with categories (if applicable): create a category → create an item with that category → get the item with its category.
Claude Code can generate both with prompts like the ones in this capsule. Make sure you have setup (e.g. setup_function or fixtures) that cleans the state between tests to avoid flakiness.
Troubleshooting
1. The E2E test only fails sometimes (flaky)
Cause: Shared state, execution order or timing.
Solution: Add a setup_function or fixture that empties the DB/storage before each test. Make sure each test creates its own data and doesn't depend on other tests. Avoid sleep(); if you need to wait, use polling or mocks.
2. The test assumes fixed ids (1, 2, 3…)
Cause: Code that uses client.get("/items/1") assuming it will always exist.
Solution: Always use the id returned by the previous request: item_id = create.json()["id"] and then client.get(f"/items/{item_id}").
3. The E2E tests take too long
Cause: Too many E2E tests or heavy setup (a real DB, external services).
Solution: Cut back to 2–5 critical E2E flows. Use an in-memory DB or SQLite for tests. Move endpoint variations to integration tests.
4. Claude Code generates Selenium/Playwright tests
Cause: The prompt doesn't make it clear that E2E here is API only, not UI.
Solution: Be explicit: "E2E for an API: complete HTTP flows with TestClient. Don't use Selenium, Playwright or a browser. Only HTTP requests against the backend."
5. The test passes but the flow fails in production
Cause: TestClient doesn't go through the same stack as production (proxy, load balancer, middleware, CORS).
Solution: E2E tests with TestClient cover the app exactly as you have it wired up in tests. To detect infrastructure problems, you'd need tests against an environment closer to production (staging). For most projects, E2E with TestClient is enough to validate the flow's logic.
Summary
- ✅ E2E for APIs = complete flows via HTTP, not browser testing; no Selenium or Playwright
- ✅ Integration verifies an isolated endpoint; E2E verifies a sequence of requests like a real client
- ✅ E2E with TestClient: create → read → update → delete → verify the deletion
- ✅ Common patterns: register → login → protected resource; parent → child → parent with children; idempotency; error flows
- ✅ Use E2E on critical flows (2–5 per project); cover the rest with integration and unit tests
- ✅ Organize them in
tests/e2e/with names that describe the flow - ✅ Claude Code generates E2E tests with prompts that specify "complete flow" and "HTTP only"
- ✅ Flakiness: avoid shared state, clean up in setup, keep tests independent, don't assume fixed ids
Next capsule: Project — Complete test pyramid (Capsule 06).
Additional Resources
- FastAPI: Testing — Official TestClient documentation
- Martin Fowler: E2E Testing — Broad stack tests vs narrow
- Google: Just Say No to More E2E Tests — When not to overuse E2E
- pytest: Fixing flaky tests — Strategies for unstable tests
- httpx: Async client — HTTP client for more advanced tests
- Test Pyramid vs Ice Cream Cone — Why not to invert the pyramid with too many E2E tests
Module 3, Capsule 05 — Testing with Claude Code Guide