REST API 21 ๐ Automated Testing (Integration & End-to-End Tests)
Automated testing for REST APIs is not a single activity but a layered strategy. Integration tests verify that your API’s componentsโrouting, validation, business logic, and database accessโwork together correctly within a realistic environment. End-to-end tests verify that complete user workflows function across the entire system, from the client request through every downstream dependency. For the REST API series, the distinction matters because each layer catches different classes of defects: integration tests catch wiring errors and data persistence bugs, while end-to-end tests catch broken contracts and cross-service failures that unit tests cannot see.
This chapter covers the architecture of automated API testing, the tools and patterns for integration tests against real dependencies, the design of end-to-end scenarios that exercise full request flows, and the CI/CD integration that makes these tests continuously valuable. It examines when to mock and when to use real services, how to manage test data without polluting production, and why schema validation is a non-negotiable part of any API test suite.
Key point: Integration tests verify that your API works with its real dependencies; end-to-end tests verify that your users’ workflows work through your API. Mocking is a tool for isolating failures, not a substitute for testing the real system.
Why integration and end-to-end tests exist
The unit test blind spot. A unit test that mocks the repository layer proves that the controller calls the mock correctly. It proves nothing about whether the database query is valid SQL, whether the foreign key constraint holds, or whether the transaction commits. Integration tests exist because the gaps between components are where production failures live. A controller that passes every unit test can still return a 500 error in production because the database migration did not run .
The contract drift problem. APIs change. A field is renamed, a status code is adjusted, a nested object becomes a flat one. Unit tests that mock the response will never catch this because the mock is written by the same person who changed the API. End-to-end tests that consume the real API through its public interface catch contract drift because they exercise the actual serialization and deserialization boundaries. Schema validation testingโasserting that responses match a JSON Schemaโcatches this systematically without requiring a full browser .
The environment parity problem. A test that passes locally but fails in CI often fails because the local environment had a dependency the CI environment lacks: a running database, a message queue, a cached credential. Integration tests that run against real services in containers (via Testcontainers or similar) eliminate this class of failure by making the test environment explicit and reproducible . The trade-off is speed: tests that start a database container take seconds instead of milliseconds, which is why they run in a separate CI stage from unit tests.
The workflow verification problem. An integration test proves that POST /orders creates an order in the database. It does not prove that a user can add an item to a cart, check out, pay, and receive a confirmation. End-to-end tests exist because workflows span multiple endpoints and multiple services, and the failure modes at the seamsโan order created but inventory not decremented, a payment captured but confirmation not sentโare invisible to tests that exercise single endpoints in isolation .
a. Integration Testing: Real Dependencies, Real Behavior
An integration test for a REST API exercises the full request path within your application’s boundary: HTTP routing, request parsing, validation, business logic, and data persistence. The defining characteristic is that it uses real implementations of your internal dependenciesโa real database, a real cache, a real message producerโrather than mocks.
The test setup isolates the application from external dependencies while keeping internal dependencies real. In a Python/FastAPI application, this means a test database that is created and destroyed per test session, with transactions rolled back between tests .
# tests/integration/test_user_api.py โ integration test with real database
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.mark.asyncio
async def test_create_user_persists_to_database(client: AsyncClient, db: AsyncSession):
# Arrange: no user exists with this email
user_data = {"email": "new@example.com", "name": "Test User"}
# Act: send the request through the HTTP layer
response = await client.post("/api/users", json=user_data)
# Assert: response status and shape
assert response.status_code == 201
data = response.json()
assert data["email"] == user_data["email"]
assert "password" not in data # Sensitive fields excluded
# Assert: the database actually contains the row
# This is what distinguishes integration from unit testing
result = await db.execute(
select(User).where(User.email == user_data["email"])
)
user = result.scalar_one()
assert user is not None
assert user.name == "Test User"
The database assertion is the critical part. A unit test with a mocked repository would pass even if the INSERT statement were invalid. The integration test fails because the real database rejects malformed SQL, enforces constraints, and returns the persisted row .
Test data management is the hardest part of integration testing. Tests must start from a known state and must not leave residue that affects subsequent tests. The pattern is to wrap each test in a transaction that is rolled back, or to truncate affected tables before each test .
# conftest.py โ fixture that provides a clean database state per test
@pytest.fixture(autouse=True)
async def clean_database(db: AsyncSession):
# Truncate tables in dependency order (children before parents)
await db.execute(text("TRUNCATE TABLE orders, users RESTART IDENTITY CASCADE"))
await db.commit()
yield
# No cleanup needed: next test truncates again
Schema validation adds a layer of protection that status codes and field checks miss. A response can have the correct status and contain the expected fields while violating the schema in subtle waysโa field that should be an integer is a string, an array that should be non-empty is empty. Validating against a JSON Schema catches these deviations before they reach a client .
# Schema validation catches structural drift
USER_SCHEMA = {
"type": "object",
"properties": {
"id": {"type": "string", "format": "uuid"},
"email": {"type": "string", "format": "email"},
"created_at": {"type": "string", "format": "date-time"}
},
"required": ["id", "email", "created_at"]
}
def test_user_response_matches_schema(client):
response = client.get("/api/users/1")
jsonschema.validate(response.json(), USER_SCHEMA)
The choice between mocking and using real dependencies is not binary. External services that you do not controlโpayment processors, email providers, third-party APIsโshould be mocked in integration tests because their availability is not guaranteed and their behavior is not your responsibility to verify . Internal dependencies that your application owns should be real in integration tests because their behavior is exactly what you need to verify.
b. End-to-End Testing: Complete Workflows Across Services
End-to-end tests exercise complete user journeys through the public interface of your system. For a REST API, this means chaining requests in the sequence a real client would make them: create a resource, retrieve it, update it, list related resources, delete it, and verify that subsequent operations behave correctly.
The scope of an end-to-end test is deliberately wider than an integration test. It may span multiple services, multiple databases, and asynchronous processes. This width is what makes end-to-end tests valuable and what makes them expensive. They are slower, more brittle, and harder to debug than integration tests, which is why they should be reserved for the workflows that matter most to the business .
A microservices order flow illustrates the pattern. The test must verify that an order created through the order service results in inventory being decremented in the inventory service and a payment being captured in the payment service .
# tests/e2e/test_order_flow.py โ end-to-end test across services
def test_complete_order_workflow(orchestrator):
scenario = TestScenario("Order and Payment Flow")
# Step 1: Create an order
order_response = scenario.http_request(
service="order",
method="POST",
endpoint="/api/orders",
json_data={"user_id": "user-1", "items": [{"sku": "ABC", "qty": 2}]},
expected_status=201
)
order_id = order_response.json()["id"]
# Step 2: Verify inventory was decremented
# This assertion checks a DIFFERENT service than the one that handled the request
scenario.assert_database(
service="inventory",
query="SELECT quantity FROM stock WHERE sku = 'ABC'",
expected_rows=[{"quantity": 8}] # Was 10 before order
)
# Step 3: Verify payment was captured
scenario.http_request(
service="payment",
method="GET",
endpoint=f"/api/payments?order_id={order_id}",
expected_status=200,
expected_json={"status": "captured"}
)
result = orchestrator.execute_scenario(scenario)
assert result["status"] == "passed"
The distinguishing feature of this test is Step 2. The assertion checks a service that did not handle the HTTP request. This cross-service verification is impossible in an integration test that only exercises one service’s boundary. It catches a class of bug that is otherwise invisible: the order service created the order and returned 201, but the message to the inventory service was never published .
End-to-end tests for REST APIs often use a declarative approach. The test scenario is defined as dataโa sequence of HTTP requests with expected responsesโrather than as imperative code. This makes the tests readable and maintainable by people who are not familiar with the test framework’s internals .
# e2e-suite.yaml โ declarative end-to-end test definition
name: User Registration Flow
defaults:
target: http://localhost:8000/api
tests:
- name: Create user
method: POST
endpoint: /users
headers:
Content-Type: application/json
body:
email: "flow@example.com"
name: "Flow Test"
expected:
status: 201
- name: Retrieve created user
method: GET
endpoint: /users/{created_user_id} # Response chaining
expected:
status: 200
body:
email: "flow@example.com"
The response chaining in the second testโ{created_user_id} extracted from the first responseโis what makes the suite an end-to-end test rather than a collection of independent requests. Each step depends on the previous step’s output, mirroring how a real client behaves.
c. CI/CD Integration and Test Data Strategy
Automated tests that do not run continuously are not automated tests; they are documentation that decays. The value of integration and end-to-end tests comes from running them on every commit, every pull request, and before every deployment. This requires a CI configuration that starts the required services, runs the tests, and reports results in a way that blocks merges on failure .
The CI pipeline should stage the tests by cost. Unit tests run first because they are fast and catch the most common errors. Integration tests run next, requiring a database container or a test database. End-to-end tests run last, requiring the full stack of services. If a unit test fails, there is no reason to spend minutes starting containers for integration tests .
# .github/workflows/api-tests.yml โ staged test execution
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest tests/unit/ -v
integration:
needs: unit # Only runs if unit tests pass
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v4
- run: pytest tests/integration/ -v
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/test
e2e:
needs: integration # Only runs if integration tests pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose up -d # Start all services
- run: ./wait-for-services.sh
- run: pytest tests/e2e/ -v
- run: docker compose down
Test data strategy is the difference between a test suite that runs reliably for years and one that becomes flaky and is eventually disabled. Three rules govern reliable test data. First, tests must create their own data and must not depend on pre-existing records. A test that assumes user ID 1 exists breaks when another test deletes it. Second, tests must clean up after themselves, or the test environment must reset between runs. Third, tests must not use production data or production-like sensitive data .
# Test data factory โ creates required records inline
def create_test_user(client, email="test@example.com"):
response = client.post("/api/users", json={"email": email, "name": "Test"})
assert response.status_code == 201
return response.json()["id"]
def test_order_requires_existing_user(client):
# Arrange: create the user this test needs
user_id = create_test_user(client, email="order-test@example.com")
# Act: attempt to create an order for that user
response = client.post("/api/orders", json={"user_id": user_id, "items": []})
# Assert: the order references the real user
assert response.status_code == 201
assert response.json()["user_id"] == user_id
The cleanup question is answered differently depending on the test type. Integration tests that run against a dedicated test database can truncate tables between tests. End-to-end tests that run against a full stack often use a fresh environment per run (via Docker Compose) or a dedicated test tenant that is reset before the suite starts .
Complete Example Session
# ============================================
# PART 1: PROJECT STRUCTURE
# ============================================
# tests/
# โโโ conftest.py โ Shared fixtures
# โโโ unit/ โ Fast, mocked tests
# โโโ integration/
# โ โโโ test_users.py โ Real DB, real HTTP layer
# โ โโโ test_orders.py
# โโโ e2e/
# โโโ test_checkout.py โ Full workflow
# โโโ scenarios/
# โโโ checkout.yaml โ Declarative scenario
# ============================================
# PART 2: INTEGRATION TEST FIXTURES
# ============================================
# tests/conftest.py โ provides client and clean database
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.main import app
from app.database import Base, get_db
@pytest.fixture
async def db():
# Create an in-memory SQLite database for integration tests
# In production CI, use PostgreSQL via Testcontainers
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
await engine.dispose()
@pytest.fixture
async def client(db):
# Override the database dependency to use the test database
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
# ============================================
# PART 3: INTEGRATION TEST WITH PERSISTENCE ASSERTION
# ============================================
# tests/integration/test_users.py
@pytest.mark.asyncio
async def test_create_user_and_verify_in_database(client, db):
# Act: create a user through the API
response = await client.post("/api/users", json={
"email": "integration@example.com",
"name": "Integration Test"
})
# Assert: HTTP response is correct
assert response.status_code == 201
user_id = response.json()["id"]
# Assert: the data is actually in the database
# This is the integration test's reason for existing
from sqlalchemy import text
result = await db.execute(
text("SELECT email FROM users WHERE id = :id"),
{"id": user_id}
)
row = result.fetchone()
assert row is not None
assert row[0] == "integration@example.com"
# ============================================
# PART 4: SCHEMA VALIDATION TEST
# ============================================
# tests/integration/test_schemas.py
import jsonschema
USER_SCHEMA = {
"type": "object",
"properties": {
"id": {"type": "integer"},
"email": {"type": "string", "format": "email"},
"created_at": {"type": "string", "format": "date-time"}
},
"required": ["id", "email", "created_at"]
}
@pytest.mark.asyncio
async def test_user_response_matches_schema(client):
# Create a user first
create = await client.post("/api/users", json={
"email": "schema@example.com", "name": "Schema"
})
user_id = create.json()["id"]
# Retrieve and validate against schema
response = await client.get(f"/api/users/{user_id}")
assert response.status_code == 200
# jsonschema raises ValidationError if the response drifts from contract
jsonschema.validate(response.json(), USER_SCHEMA)
# ============================================
# PART 5: END-TO-END SCENARIO DEFINITION
# ============================================
# tests/e2e/scenarios/checkout.yaml
name: Checkout Flow
defaults:
target: http://localhost:8000/api
tests:
- name: Create cart
method: POST
endpoint: /carts
expected:
status: 201
- name: Add item to cart
method: POST
endpoint: /carts/{cart_id}/items
body:
sku: "WIDGET-1"
quantity: 2
expected:
status: 200
- name: Submit checkout
method: POST
endpoint: /checkout
body:
cart_id: "{cart_id}"
expected:
status: 201
body:
status: "confirmed"
# ============================================
# PART 6: END-TO-END TEST RUNNER
# ============================================
# tests/e2e/test_checkout.py
import yaml
import httpx
def test_checkout_scenario():
# Load the declarative scenario
with open("tests/e2e/scenarios/checkout.yaml") as f:
scenario = yaml.safe_load(f)
base_url = scenario["defaults"]["target"]
context = {} # Stores extracted values between steps
with httpx.Client(base_url=base_url) as client:
for step in scenario["tests"]:
# Substitute placeholders from previous responses
endpoint = step["endpoint"].format(**context)
method = step["method"]
body = step.get("body", {})
response = client.request(method, endpoint, json=body)
# Assert status
expected_status = step["expected"]["status"]
assert response.status_code == expected_status, \
f"{step['name']} failed: {response.text}"
# Extract values for subsequent steps
data = response.json()
if "id" in data:
context[f"{step['name'].lower().replace(' ', '_')}_id"] = data["id"]
# Map extracted IDs to the placeholder names used in endpoints
if "cart_id" not in context and "id" in data:
context["cart_id"] = data["id"]
# ============================================
# PART 7: DATABASE ASSERTION IN E2E
# ============================================
# After the HTTP flow, verify side effects in the database
# This uses a separate connection to the real test database
from sqlalchemy import create_engine, text
def test_inventory_decremented_after_checkout():
# Run the checkout scenario first (or use its output)
# Then query the inventory service's database directly
engine = create_engine("postgresql://test:test@localhost:5432/inventory")
with engine.connect() as conn:
result = conn.execute(
text("SELECT quantity FROM stock WHERE sku = 'WIDGET-1'")
)
quantity = result.scalar()
# Original quantity was 100; 2 were ordered
assert quantity == 98
# ============================================
# PART 8: CI PIPELINE STAGING
# ============================================
# .github/workflows/test.yml
# Stage 1: unit tests (fast, no dependencies)
# Stage 2: integration tests (database container)
# Stage 3: e2e tests (full stack via docker compose)
# Each stage only runs if the previous stage passed
# ============================================
# PART 9: TEST DATA FACTORY
# ============================================
# tests/factories.py โ creates valid records without depending on fixtures
def create_test_user(client, **overrides):
defaults = {"email": f"user-{uuid4()}@test.com", "name": "Test User"}
defaults.update(overrides)
response = client.post("/api/users", json=defaults)
assert response.status_code == 201
return response.json()
# ============================================
# PART 10: MOCKING EXTERNAL SERVICES
# ============================================
# External services (payment, email) are mocked; internal are real
from unittest.mock import patch
def test_checkout_with_mocked_payment(client, db):
with patch("app.services.payment.capture") as mock_capture:
mock_capture.return_value = {"status": "captured", "id": "pay_123"}
# Real database, real HTTP layer, mocked external payment
response = client.post("/api/checkout", json={"cart_id": 1})
assert response.status_code == 201
# Verify the mock was called with the expected amount
mock_capture.assert_called_once()
The ten parts trace the full testing stack: fixtures that provide a clean database, integration tests that assert persistence, schema validation that catches contract drift, declarative end-to-end scenarios that chain requests, cross-service database assertions, CI staging, data factories, and the boundary between mocked external services and real internal ones.
Quick Reference
Test Types and Scope
| Test Type | Scope | Dependencies | Speed |
|---|---|---|---|
| Unit | Single function/class | All mocked | Milliseconds |
| Integration | API boundary + database | Real DB, mocked external | Seconds |
| End-to-End | Full workflow | Real everything | Seconds to minutes |
| Schema validation | Response structure | Real API | Milliseconds |
Integration Test Checklist
| Check | Why It Matters |
|---|---|
| Status code matches | Verifies routing and error handling |
| Response body fields present | Verifies serialization |
| Database row exists | Verifies persistence, not just return value |
| Schema validates | Catches structural drift |
| Cleanup runs between tests | Prevents inter-test dependencies |
End-to-End Test Design Rules
| Rule | Rationale |
|---|---|
| Chain requests in workflow order | Mirrors real client behavior |
| Verify side effects in other services | Catches async/message failures |
| Use declarative scenarios | Readable, maintainable by non-developers |
| Run against full stack | Catches integration failures |
Best Practices
โ Do This:
# Assert database state after HTTP response
assert response.status_code == 201
assert await db.fetch_one("SELECT email FROM users") is not None # โ
# Validate responses against JSON Schema
jsonschema.validate(response.json(), USER_SCHEMA) # โ
# Mock external services only
with patch("app.services.payment.capture"): # โ
# Create test data inline; do not depend on seed data
user_id = create_test_user(client, email="test@example.com") # โ
# Use declarative scenarios for end-to-end flows
- name: Add item
method: POST
endpoint: /carts/{cart_id}/items # โ
# Stage CI: unit โ integration โ e2e
needs: unit # โ
โ Don’t Do This:
# Test only the HTTP status without checking persistence
assert response.status_code == 201 # โ
# Mock the database in an integration test
with patch("app.repositories.user_repository"): # โ
# Use production data or hardcoded IDs
client.get("/api/users/1") # โ
# Skip cleanup between tests
# Tests pass alone but fail in suite # โ
# Run e2e before integration in CI
e2e:
needs: [] # Runs without waiting for integration # โ
# Validate only field presence, not schema
assert "email" in response.json() # โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Integration test passes but production fails | Mocked database hides invalid SQL | Use real database in integration tests |
| Schema drift undetected | Only field presence checked | Add JSON Schema validation |
| Flaky e2e tests | Test data leaks between runs | Reset environment per suite or use factories |
| Slow CI feedback | All tests run in one stage | Stage unit โ integration โ e2e |
| Cross-service bug missed | Only one service verified | Assert side effects in downstream services |
| External API flakiness | Real external calls in tests | Mock external, keep internal real |
Real-World Examples
1. Integration Test with Database Assertion
response = client.post("/api/users", json=data)
assert response.status_code == 201
assert db.execute("SELECT 1 FROM users WHERE id = :id", {"id": response.json()["id"]}).fetchone()
2. Schema Validation
jsonschema.validate(response.json(), USER_RESPONSE_SCHEMA)
3. Mock External Service Only
with patch("app.services.email.send") as mock_send:
client.post("/api/users", json={"email": "test@example.com"})
mock_send.assert_called_once()
4. Test Data Factory
def create_test_order(client, user_id=None, **kwargs):
if user_id is None:
user_id = create_test_user(client)["id"]
return client.post("/api/orders", json={"user_id": user_id, **kwargs}).json()
5. Declarative E2E Step
- name: Get user
method: GET
endpoint: /users/{user_id}
expected:
status: 200
6. Cross-Service Assertion
# After HTTP call, verify inventory DB directly
quantity = inventory_db.execute("SELECT quantity FROM stock WHERE sku = 'ABC'").scalar()
assert quantity == 98
7. CI Stage with Database Service
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
8. Response Chaining in E2E
cart_id = response.json()["id"]
next_response = client.post(f"/carts/{cart_id}/items", json=item)
9. Clean Database Fixture
@pytest.fixture(autouse=True)
async def clean_db(db):
await db.execute(text("TRUNCATE users CASCADE"))
await db.commit()
10. E2E with Docker Compose
docker compose up -d && ./wait-for-health.sh && pytest tests/e2e/
Visual
The Test Pyramid for REST APIs
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TEST PYRAMID โ
โ โ
โ โโโโโโโโโโโ โ
โ โ E2E โ Full workflows โ
โ โ Tests โ All services real โ
โ โ (few) โ Slow, high confidence โ
โ โโโโโโฌโโโโโ โ
โ โ โ
โ โโโโโโโโโดโโโโโโโโ โ
โ โ Integration โ API + real DB โ
โ โ Tests โ External mocked โ
โ โ (moderate) โ Catches wiring bugs โ
โ โโโโโโโโโฌโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโดโโโโโโโโโโโ โ
โ โ Unit Tests โ Single function โ
โ โ (many) โ All mocked โ
โ โ โ Fast, catches logic โ
โ โโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Each layer catches what the layer above cannot. โ
โ Unit tests prove logic. Integration proves wiring. โ
โ E2E proves workflows. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Integration Test Scope
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ INTEGRATION TEST BOUNDARY โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ YOUR APPLICATION โ โ
โ โ โ โ
โ โ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ โ โ
โ โ โ Router โโโบโValidationโโโบโ BusinessโโโบโRepositoryโ โ โ
โ โ โ โ โ โ โ Logic โ โ โ โ โ
โ โ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโฌโโโโโ โ โ
โ โ โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ REAL DATABASE โ โ MOCKED EXTERNAL โ โ
โ โ โ โ SERVICES โ โ
โ โ Integration โ โ โ โ
โ โ tests verify โ โ Payment, email, โ โ
โ โ persistence โ โ third-party API โ โ
โ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Real: everything you own. โ
โ Mocked: everything you do not control. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
End-to-End Workflow Chain
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ E2E WORKFLOW: CHECKOUT โ
โ โ
โ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโ โ
โ โ POST โโโโโบโ POST โโโโโบโ POST โโโโโบโ GET โ โ
โ โ /carts โ โ /items โ โ /checkoutโ โ /orders โ โ
โ โโโโโโฌโโโโโ โโโโโโฌโโโโโ โโโโโโฌโโโโโ โโโโโโฌโโโโโ โ
โ โ โ โ โ โ
โ โผ โผ โผ โผ โ
โ cart_id item added payment order โ
โ extracted to cart captured confirmed โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ CROSS-SERVICE ASSERTIONS โ โ
โ โ โ โ
โ โ Inventory DB: quantity decreased by 2 โ โ
โ โ Payment DB: transaction status = "captured" โ โ
โ โ Email queue: confirmation message enqueued โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The HTTP responses are necessary but not sufficient. โ
โ Side effects in other services prove the workflow worked. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
CI Pipeline Staging
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PUSH TO BRANCH โ
โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Stage 1: Unit โ pytest tests/unit/ โ
โ โ (30 seconds) โ No dependencies โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ PASS โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Stage 2: โ pytest tests/integration/ โ
โ โ Integration โ PostgreSQL container โ
โ โ (2 minutes) โ Real database โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ PASS โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Stage 3: E2E โ pytest tests/e2e/ โ
โ โ (5 minutes) โ docker compose up โ
โ โ โ Full stack โ
โ โโโโโโโโโโฌโโโโโโโโโ โ
โ โ PASS โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ Merge allowed โ โ
โ โโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Failure at any stage blocks the merge. โ
โ Later stages never run if earlier stages fail. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Integration test scope | API boundary + real database |
| Integration test assertion | HTTP status + database row exists |
| Schema validation | JSON Schema check on response structure |
| External services | Mocked in integration tests |
| Internal dependencies | Real in integration tests |
| End-to-end scope | Full workflow across services |
| E2E cross-service check | Assert database in downstream service |
| Test data rule | Create inline, do not depend on seeds |
| CI staging order | Unit โ Integration โ E2E |
| Declarative E2E format | YAML scenario with response chaining |
Key takeaways:
- Integration tests assert persistence, not just responses. A test that checks only the status code and response body is a glorified unit test with a real HTTP client. The database assertion is what proves the request actually did something .
- Mock external services, keep internal dependencies real. Payment processors and email providers are not your responsibility to verify. Your database and routing layer are. The boundary between mocked and real should follow ownership .
- Schema validation catches contract drift before clients do. Field presence checks miss type changes, format violations, and structural shifts. JSON Schema validation catches all of these systematically .
- End-to-end tests verify workflows, not endpoints. The critical assertion is often in a service that did not handle the HTTP requestโinventory decremented, payment captured, email enqueued. These cross-service checks are the only way to catch seam failures .
- Test data must be created inline and cleaned up. Tests that depend on pre-existing records break when those records change. Tests that leave residue break subsequent tests. Factories and truncation fixtures solve both .
- CI staging is about feedback speed. Unit tests should fail within seconds. Integration tests within minutes. End-to-end tests are the final gate. Running them in parallel or out of order wastes CI resources on failures that earlier stages would have caught .
Remember: Integration and end-to-end tests exist because the gaps between components are where production failures live. A unit test proves that a function returns the expected value when given the expected input. It does not prove that the database accepts the INSERT, that the downstream service receives the message, or that the user’s workflow completes. The integration test closes the first gap by using real dependencies within your boundary. The end-to-end test closes the second by verifying side effects across boundaries. Neither is optional for a production REST API, and neither substitutes for the other. The test pyramid is not a hierarchy of importance; it is a division of responsibility. Each layer catches what the others cannot, and a complete suite runs all three in the order that gives the fastest feedback on the most likely failures.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!