Module 3: Integration and E2E Tests
The Test Pyramid: Strategy and Trade-offs
The Test Pyramid: Strategy and Trade-offs
Capsule overview
You have limited time. You have a testing budget. Which tests do you write first? And how many of each type? The test pyramid isn't a dogmatic rule — it's a decision framework that helps you maximize confidence while minimizing cost. In this capsule you'll learn the strategy behind the three levels, the trade-offs of each one, and how to use Claude Code to generate tests at the right level with the right prompt.
Without understanding the pyramid, you tend to do one of two things: write only unit tests (fast but they don't catch integration failures) or write too many E2E tests (slow, fragile, expensive to maintain). The pyramid gives you the judgment to balance speed, confidence, and cost.
By the end, you'll have a clear strategy: what to test at each level, how many tests at each one, and the exact prompts for Claude Code to generate tests at the appropriate level.
The Test Pyramid Explained
The three levels
The test pyramid divides tests into three categories according to what they verify and how much they cost:
/\
/ \
/ E2E \ ← Few, slow, high confidence
/──────\
/ \
/Integration\ ← Medium, medium speed
/────────────\
/ \
/ Unit \ ← Many, fast, isolated
/──────────────────\
Unit tests (base):
- Speed: milliseconds per test
- Scope: isolated functions or classes, no external dependencies
- What they test: pure logic, calculations, validations, transformations
- Maintenance cost: very low
Integration tests (middle):
- Speed: seconds per test
- Scope: interaction between components (HTTP endpoints, database, services)
- What they test: that the pieces fit together correctly — the endpoint receives the request and the DB saves the data
- Maintenance cost: medium
E2E tests (top):
- Speed: seconds to minutes
- Scope: a complete user flow from start to finish
- What they test: that the system works as a whole — register → login → create resource → read → update → delete
- Maintenance cost: high
Visual representation with proportions
Typical recommended proportion: 70% unit, 20% integration, 10% E2E.
Number of tests (approximate)
Unit: ████████████████████████████████████████ ~70%
Integration: ████████████ ~20%
E2E: ██████ ~10%
Execution time (inverted - unit is fast)
Unit: ██ <1 min total
Integration: ████████ ~1-5 min
E2E: ████████████████████████████ 5-30 min
The base is wide because unit tests are cheap to write and run. The top is narrow because E2E tests are expensive. Investing in many unit tests gives you fast feedback without sacrificing the speed of the development cycle.
Trade-offs at Each Level
| Criterion | Unit | Integration | E2E |
|---|---|---|---|
| Speed | Milliseconds | Seconds | Seconds-Minutes |
| Confidence | Logic only | Interfaces between components | Complete system |
| Maintenance cost | Low | Medium | High |
| Flakiness | Very low | Low | High |
| Setup complexity | None | Some (DB, TestClient) | Significant |
Practical interpretation
- ✅ Unit: If a unit test fails, you know the function's logic is wrong. But you don't know whether the endpoint exposes that logic correctly.
- ✅ Integration: If an integration test fails, you know there's a problem at the interface (endpoint ↔ DB, endpoint ↔ service). Not necessarily in the internal logic.
- ✅ E2E: If an E2E test fails, you know something is broken in the complete flow. But you don't know where — it could be the frontend, the API, the DB, or the network.
Flakiness = tests that sometimes pass and sometimes fail without you changing the code. E2E tests are flaky because they depend on many components — network timeouts, execution order, DB state. Unit tests are almost never flaky because there are no external dependencies.
Speed in practice
In a typical project with 100 unit tests, 30 integration, and 10 E2E:
Unit (100 tests): ~3-5 seconds total
Integration (30): ~15-30 seconds
E2E (10): ~2-5 minutes
Total: ~3-6 minutes for the complete suite
If you invert the proportion (10 unit, 30 integration, 100 E2E), the suite could take 30-60 minutes. The feedback loop breaks — nobody runs the tests before committing.
How Many Tests at Each Level
Rule of thumb: 70% / 20% / 10%
- ~70% unit: Business logic, validations, calculations, transformations
- ~20% integration: Endpoints, DB queries, calls between services
- ~10% E2E: Critical user flows (login, complete CRUD, checkout)
It depends on the project
| Project type | Unit | Integration | E2E |
|---|---|---|---|
| API-heavy (CRUD REST) | 60% | 30% | 10% |
| Complex logic (calculator, engine) | 80% | 15% | 5% |
| Critical flows (payments, auth) | 65% | 20% | 15% |
| Simple, few endpoints | 70% | 20% | 10% |
If your project is an API with many endpoints and little complex business logic, you'll have more integration tests. If it's a calculation engine with few endpoints, you'll have more unit tests.
The "Ice Cream Cone" anti-pattern
/\
/ \
/ \ ← Too many E2E
/ E2E \
/────────\
/ \
/ Unit \ ← Too few unit
/──────────────\
Symptoms:
- The suite takes 30+ minutes to run
- Tests fail randomly (flaky)
- Any change breaks dozens of tests
- Nobody wants to run the tests before committing
Solution: Reduce E2E, increase unit and integration. Unit tests give fast feedback and aren't flaky. E2E should be reserved for truly critical flows.
What to Test at Each Level
Unit: Isolated logic
✅ Pure functions (input → output)
✅ Calculations (discounts, totals, averages)
✅ Validations (email, format, ranges)
✅ Data transformations (parse, format)
✅ Business rules that don't touch I/O
❌ HTTP calls
❌ Database access
❌ File system
❌ External services
Integration: Interactions between components
✅ HTTP endpoints (status codes, response body, headers)
✅ DB queries (CRUD, relationships)
✅ Service ↔ repository interaction
✅ Serialization/deserialization (request → model → DB)
✅ Authentication at the endpoint level
❌ Complete flow across multiple endpoints
❌ UI or browser
❌ Multiple orchestrated services (that's E2E)
E2E: Complete flows
✅ Register user → login → create resource → read → update → delete
✅ Complete checkout flow
✅ Authentication → protected operation
✅ Critical business cases from start to finish
❌ Every variation of every endpoint (that's integration)
❌ Logic edge cases (that's unit)
Claude Code Prompts by Level
The level of the test is defined in the prompt. If you ask for "tests" without specifying, Claude Code will tend toward unit tests by default.
Unit tests
Generate unit tests for the function [name] in [file].
The function [briefly describe what it does].
Cover:
- Happy path
- Edge cases (empty, None, limits)
- Error handling (invalid inputs)
Use pytest, one assert per test, descriptive names.
Concrete example:
Generate unit tests for the validate_todo_title function in todos.py.
The function validates that the title is between 1 and 200 characters and isn't only spaces.
Cover: valid title, empty, too long, only spaces, None.
Use pytest, one assert per test.
Integration tests
Generate integration tests for the [method] [route] endpoint of the FastAPI API.
Use FastAPI's TestClient. Verify:
- Correct status code
- The structure of the response body
- Persistence in the DB when applicable
Cover: success case, validation errors, not found.
Concrete example:
Generate integration tests for the POST /todos endpoint of the API.
Use TestClient. Verify that it returns 201 with the created todo in the body,
and that the todo exists in the database.
Cover: valid todo, empty title (422), too-long title (422).
E2E tests
Generate an E2E test for the complete flow: create todo → get it → update it → delete it.
Use httpx or TestClient. The test must:
1. Create a todo via POST
2. Get it via GET with the id
3. Update it via PUT
4. Delete it via DELETE
5. Verify that GET no longer finds it (404)
A single test that validates the complete lifecycle.
Concrete example:
Generate an E2E test that validates the complete CRUD flow for todos.
Steps: POST create todo → GET by id → PUT update → DELETE → GET verify 404.
A single test, complete flow.
Key differences
| Level | The prompt specifies | Claude Code generates |
|---|---|---|
| Unit | "unit tests for this function" | Isolated-function tests, no HTTP or DB |
| Integration | "integration tests for this endpoint" | TestClient, status and body assertions |
| E2E | "E2E test for this flow" | A sequence of requests, complete flow |
Common mistakes when asking Claude Code for tests:
- Asking for "tests" without specifying the level → usually generates unit tests by default
- Giving FastAPI context and asking for "tests" → may generate integration without your intending it
- Asking for "a test" for a flow → may generate a single test that mixes unit+integration; for E2E you must say "complete flow" or "lifecycle"
Real Example: Todo App at All 3 Levels
The application code
# main.py (FastAPI app)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
# In-memory store for simplicity (in a real project it would be a DB)
todos_db: dict[int, dict] = {}
_id_counter = 0
class TodoCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
class TodoUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=200)
class TodoResponse(BaseModel):
id: int
title: str
def validate_todo_title(title: str) -> str:
"""Pure validation logic — tested at unit level."""
if not title or not title.strip():
raise ValueError("Title cannot be empty")
if len(title) > 200:
raise ValueError("Title must be at most 200 characters")
return title.strip()
@app.post("/todos", response_model=TodoResponse)
def create_todo(todo: TodoCreate):
global _id_counter
validated = validate_todo_title(todo.title)
_id_counter += 1
todo_obj = {"id": _id_counter, "title": validated}
todos_db[_id_counter] = todo_obj
return todo_obj
@app.get("/todos/{todo_id}", response_model=TodoResponse)
def get_todo(todo_id: int):
if todo_id not in todos_db:
raise HTTPException(404, "Todo not found")
return todos_db[todo_id]
@app.put("/todos/{todo_id}", response_model=TodoResponse)
def update_todo(todo_id: int, todo: TodoUpdate):
if todo_id not in todos_db:
raise HTTPException(404, "Todo not found")
if todo.title is not None:
todos_db[todo_id]["title"] = validate_todo_title(todo.title)
return todos_db[todo_id]
@app.delete("/todos/{todo_id}", status_code=204)
def delete_todo(todo_id: int):
if todo_id not in todos_db:
raise HTTPException(404, "Todo not found")
del todos_db[todo_id]
Unit: test_create_todo_validates_title
# tests/unit/test_todo_validation.py
import pytest
from main import validate_todo_title
def test_valid_title_returns_stripped():
assert validate_todo_title(" Buy milk ") == "Buy milk"
def test_empty_string_raises():
with pytest.raises(ValueError, match="cannot be empty"):
validate_todo_title("")
def test_whitespace_only_raises():
with pytest.raises(ValueError, match="cannot be empty"):
validate_todo_title(" \t\n ")
def test_title_too_long_raises():
with pytest.raises(ValueError, match="at most 200"):
validate_todo_title("a" * 201)
def test_title_exactly_200_chars_ok():
result = validate_todo_title("a" * 200)
assert len(result) == 200
Fast, isolated, without spinning up the API. It tests only the validation logic.
Integration: test_post_todos_returns_201
# tests/integration/test_todos_api.py
from fastapi.testclient import TestClient
from main import app, todos_db
client = TestClient(app)
def setup_function():
todos_db.clear()
def test_post_todos_returns_201():
response = client.post("/todos", json={"title": "Learn pytest"})
assert response.status_code == 201
data = response.json()
assert "id" in data
assert data["title"] == "Learn pytest"
assert data["id"] in todos_db
def test_post_todos_empty_title_returns_422():
response = client.post("/todos", json={"title": ""})
assert response.status_code == 422
def test_get_todo_returns_200():
create_resp = client.post("/todos", json={"title": "Test todo"})
todo_id = create_resp.json()["id"]
response = client.get(f"/todos/{todo_id}")
assert response.status_code == 200
assert response.json()["title"] == "Test todo"
def test_get_todo_not_found_returns_404():
response = client.get("/todos/99999")
assert response.status_code == 404
Uses TestClient. It verifies status codes, response body, and that the data persisted in todos_db. It doesn't test the complete flow of multiple chained requests.
E2E: test_full_todo_lifecycle_create_read_update_delete
# tests/e2e/test_todo_lifecycle.py
from fastapi.testclient import TestClient
from main import app, todos_db
client = TestClient(app)
def setup_function():
todos_db.clear()
def test_full_todo_lifecycle_create_read_update_delete():
# 1. Create
create_resp = client.post("/todos", json={"title": "E2E todo"})
assert create_resp.status_code == 201
todo_id = create_resp.json()["id"]
# 2. Read
get_resp = client.get(f"/todos/{todo_id}")
assert get_resp.status_code == 200
assert get_resp.json()["title"] == "E2E todo"
# 3. Update
update_resp = client.put(f"/todos/{todo_id}", json={"title": "E2E todo updated"})
assert update_resp.status_code == 200
assert update_resp.json()["title"] == "E2E todo updated"
# 4. Delete
delete_resp = client.delete(f"/todos/{todo_id}")
assert delete_resp.status_code == 204
# 5. Verify gone
get_after_resp = client.get(f"/todos/{todo_id}")
assert get_after_resp.status_code == 404
A single test that validates the complete lifecycle. If this passes, you know the CRUD flow works end to end.
Summary of the example: same feature, three approaches
| Level | What it verifies | Dependencies |
|---|---|---|
| Unit | validate_todo_title rejects empty, too long, accepts 200 chars | None (pure function) |
| Integration | POST returns 201, correct body, data in DB; GET 404 for a nonexistent id | FastAPI app, TestClient, todos_db |
| E2E | Create → read → update → delete → 404 | The whole app, complete flow |
If you change the title validation (e.g., max 100 chars), the unit test fails first. If you break the POST endpoint (e.g., a typo in the path), the integration test fails. If you break something in the flow (e.g., delete doesn't actually delete), the E2E fails.
Recommended folder structure
To keep the pyramid clear in the code:
project/
├── src/
│ └── main.py
├── tests/
│ ├── unit/
│ │ ├── test_validation.py
│ │ └── test_utils.py
│ ├── integration/
│ │ ├── test_todos_api.py
│ │ └── test_auth_endpoints.py
│ ├── e2e/
│ │ └── test_todo_lifecycle.py
│ └── conftest.py # Shared fixtures
pytest can run by level:
pytest tests/unit/ # Only unit (fast development)
pytest tests/integration/ # Unit + integration (pre-commit)
pytest tests/ # Complete suite (CI)
Tip: During TDD, run only the unit tests — feedback in seconds. Before pushing, run unit + integration. E2E only in CI or when you change critical flows.
Comparison Table: When to Use Each Level
| Situation | Use |
|---|---|
Verify that calculate_discount(100, 10) returns 90 | Unit |
| Verify that a function correctly validates the email | Unit |
Verify that POST /todos returns 201 and saves to the DB | Integration |
Verify that GET /todos/1 returns 404 if it doesn't exist | Integration |
| Verify that the register→login→create resource flow works | E2E |
| Verify that a change in the endpoint breaks the responses | Integration |
| Verify that a change in the validation logic breaks the endpoint | Unit + Integration |
| Detect regressions in critical flows before deploy | E2E |
| Fast feedback during development (TDD) | Unit |
| Validate that the API meets the expected contract | Integration |
Exercises
Exercise 1: Classify tests (Easy)
Classify each test as Unit, Integration, or E2E:
test_calculate_total_with_tax_returns_correct_amount()test_post_users_returns_201_with_user_in_db()test_login_then_create_post_then_delete_post()test_validate_password_rejects_short_password()test_get_product_returns_404_when_not_found()
See solution
- Unit — Tests an isolated calculation function.
- Integration — Tests that the POST endpoint persists to the DB.
- E2E — Complete flow: login → create → delete.
- Unit — Tests validation logic.
- Integration — Tests the GET endpoint and its 404 response.
Exercise 2: Choose the correct level (Medium)
You have a products API. Which level would you use to verify each of these behaviors?
- a) The price with tax is calculated as
price * 1.21 - b) When you
POST /products, the product appears inGET /products/{id} - c) A user can register, log in, create a product, and see it in their list
See solution
- a) Unit — Pure calculation, no HTTP or DB.
- b) Integration — Verifies that the POST endpoint persists and that GET retrieves it. Endpoint ↔ storage interaction.
- c) E2E — Complete user flow: register → login → create → read. Multiple chained endpoints.
Exercise 3: Detect the anti-pattern (Medium)
A team has 150 tests: 20 unit, 30 integration, 100 E2E. The suite takes 45 minutes. Identify the problem and propose a redistribution.
See solution
Problem: Ice cream cone — too many E2E, too few unit. The suite is slow and probably flaky.
Redistribution proposal (example):
- Unit: 100+ (business logic, validations, calculations)
- Integration: 40 (main endpoints, CRUD)
- E2E: 10 (critical flows: auth, checkout, complete CRUD)
Expected result: Suite in 5-15 min, less flakiness, faster feedback.
Exercise 4: Write prompts for Claude Code (Medium)
You have a function format_price(amount: float, currency: str) -> str and an endpoint GET /products/{id}. Write the prompts you'd use to ask Claude Code for:
- Unit tests for
format_price - Integration tests for
GET /products/{id}
See solution
1. Unit tests for format_price:
Generate unit tests for the function format_price(amount: float, currency: str) -> str.
The function formats the amount according to the currency (EUR → "10,50 €", USD → "$10.50").
Cover: valid amounts, zero, negatives, unknown currency.
Use pytest, descriptive names.
2. Integration tests for GET /products/{id}:
Generate integration tests for the GET /products/{id} endpoint of a FastAPI API.
Use TestClient. Verify:
- 200 when the product exists, body with id, name, price
- 404 when it doesn't exist
Cover both cases.
Exercise 5: Implement the 3 levels for a function (Hard)
You have def add(a: int, b: int) -> int in an endpoint POST /calc that receives {"a": 1, "b": 2} and returns {"result": 3}. Write:
- A unit test for
add - An integration test for
POST /calc - An E2E test that uses the result of a POST in a subsequent operation (e.g., POST calc, then GET something that uses that result — if it doesn't apply, design a mini-flow)
For the E2E, if the API has no chained flow, invent a simple one: for example, POST /calc saves the last result and GET /calc/last returns it. E2E: POST → GET /calc/last.
See solution
1. Unit:
# tests/unit/test_add.py
import pytest
from main import add
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_with_zero():
assert add(0, 5) == 5
def test_add_negative():
assert add(-1, 1) == 0
2. Integration:
# tests/integration/test_calc_api.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_post_calc_returns_200_with_result():
response = client.post("/calc", json={"a": 1, "b": 2})
assert response.status_code == 200
assert response.json()["result"] == 3
3. E2E (assuming GET /calc/last):
# tests/e2e/test_calc_flow.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_calc_then_get_last():
client.post("/calc", json={"a": 10, "b": 20})
response = client.get("/calc/last")
assert response.status_code == 200
assert response.json()["result"] == 30
If GET /calc/last doesn't exist, the E2E could be: two sequential POSTs and verifying that the API maintains consistent state (per the design). The point is that E2E validates a flow of multiple requests.
Exercise 6: Detect which level failed (Medium)
You have an orders API. A bug makes the discounted total calculate incorrectly. You have tests at all 3 levels. Which test would fail first if the bug is in: a) the calculate_discounted_total function, b) the endpoint that doesn't correctly receive the discount_percent from the body, c) the complete flow that doesn't pass the coupon between steps?
See solution
- a) Unit — If the bug is in
calculate_discounted_total, the unit test that calls that function fails. It's the first level that runs it directly. - b) Integration — If the function is fine but the endpoint doesn't parse/receive the
discount_percentcorrectly, the integration test for the endpoint fails. The function's unit test would pass. - c) E2E — If the bug is that the coupon isn't propagated between steps of the flow (e.g., create cart → apply coupon → checkout), only the E2E that goes through the whole flow catches it. The unit and integration of each piece could pass.
Exercise 7: Design the pyramid for your project (Medium)
Imagine a REST API for tasks with: create, list, get by id, update, delete. There's title validation (1-200 chars) and a completed: bool field. Estimate how many tests of each type you'd have and what they'd cover.
See solution
Example estimate:
| Level | Count | What they cover |
|---|---|---|
| Unit | ~12 | validate_title (5 cases), format_task_for_response (2-3), completed filter logic (2-3) |
| Integration | ~15 | POST (201, 422), GET list (200 empty, 200 with items), GET by id (200, 404), PUT (200, 404, 422), DELETE (204, 404) |
| E2E | ~2 | Complete CRUD flow; flow of create several → filter by completed |
Total: ~29 tests. Approximate proportion 40% unit, 50% integration, 10% E2E. For a CRUD API it's reasonable to have more integration than unit if the business logic is minimal.
Project Connection
This module's project consists of building a complete test pyramid for an item CRUD REST API. This capsule gives you the strategy:
- Unit: Validations, formatting, item business rules.
- Integration: Each endpoint (GET, POST, PUT, DELETE) with TestClient, verifying status and body.
- E2E: At least one complete flow: create item → read it → update it → delete it → verify 404.
In capsule 03 you'll use FastAPI TestClient to implement the integration tests. The pyramid you design here (what to test at each level) materializes in the coming capsules.
Troubleshooting
1. "Why not write only E2E? They give more confidence."
Cause: Confusing confidence with efficiency. E2E gives confidence in the complete flow, but they're slow, flaky, and hard to maintain. If everything is E2E, the feedback cycle becomes unsustainable.
Solution: Use E2E for the flows that truly matter (3-5 per typical project). The rest of the confidence comes from unit + integration, which are fast and stable.
2. "My unit tests pass but the endpoint fails in production"
Cause: Unit tests verify the isolated logic. They don't verify serialization, routing, middleware, or the integration with the real DB.
Solution: Add integration tests. If validate_todo_title works in unit but the endpoint returns 500, the problem is in the integration (Pydantic, endpoint, DB). Integration tests catch it.
3. "The E2E tests are flaky — sometimes they pass, sometimes not"
Cause: External dependencies: timeouts, execution order, shared state between tests, an unclean DB.
Solution: Make sure each E2E has setup/teardown that cleans the state. Use in-memory databases or ephemeral containers. Avoid time dependencies (use mocks for datetime if applicable). If an E2E is still flaky, consider downgrading it to integration.
4. "I don't know if a test should be unit or integration"
Cause: Unclear criteria. The key question: does it touch I/O (HTTP, DB, files)?
Solution: If the test makes no HTTP requests and doesn't access the DB → unit. If it makes requests (TestClient) or uses a real/test DB → integration. If it chains multiple requests in a user flow → E2E.
5. "Claude Code generates integration tests when I ask for unit tests"
Cause: The prompt doesn't specify the level clearly enough, or the context (a file with a FastAPI app) makes Claude Code assume integration.
Solution: Be explicit: "Generate unit tests for the function X. Don't use TestClient or make HTTP requests. Test only the isolated function." Include the function signature and what it should do.
Summary
- ✅ The test pyramid has three levels: unit (base), integration (middle), E2E (top)
- ✅ Trade-offs: unit is fast and cheap; integration gives confidence in interfaces; E2E validates the complete system but is slow and fragile
- ✅ Typical proportion: ~70% unit, ~20% integration, ~10% E2E (varies by project)
- ✅ Avoid the ice cream cone: too many E2E and too few unit
- ✅ Unit: pure logic, validations, calculations. Integration: endpoints, DB. E2E: complete flows
- ✅ The prompt defines the level: "unit tests for this function" vs "integration tests for this endpoint" vs "E2E for this flow"
- ✅ The same feature is tested differently at each level; the todo app example shows all three approaches
Next capsule: API testing with FastAPI TestClient — implementing integration tests with GET, POST, PUT, DELETE.
Additional Resources
- Martin Fowler: Test Pyramid — The origin of the concept
- Ham Vocke: The Practical Test Pyramid — A detailed practical guide with examples
- FastAPI: Testing — Official TestClient documentation
- Kent C. Dodds: Testing Trophy — An alternative view (testing trophy) and classifications
- Google: Testing Blog — Just Say No to More End-to-End Tests — Why not to overuse E2E
- pytest: Organizing test directory — Folder structure for unit/integration/e2e
Module 3, Capsule 02 — Testing with Claude Code Guide The pyramid is strategy, not dogma: balance speed, confidence, and cost