Testing Pyramid for APIs — Unit, Integration, and E2E Testing Strategies
In this tutorial, you will learn about Testing Pyramid for APIs. We cover key concepts, practical examples, and best practices to help you master this topic.
The testing pyramid for APIs recommends a balanced approach: many fast unit tests, fewer integration tests, and a small number of end-to-end tests to verify complete workflows.
What You'll Learn
The three layers of the API testing pyramid, appropriate test coverage for each layer, test execution speed trade-offs, and when to use each type of test.
Why It Matters
An unbalanced test suite wastes time and money. Too many E2E tests create slow, flaky CI pipelines. Too few unit tests miss bugs early. The pyramid guides efficient test investment.
Code Examples, Practice, FAQ, and Mini Project.
Code Example: Unit Test for API Validation Function
import pytest
from api.validation import validate_email, validate_threat_score
def test_validate_email_valid():
assert validate_email("user@example.com") == True
def test_validate_email_invalid():
assert validate_email("not-an-email") == False
assert validate_email("") == False
assert validate_email("@missing.com") == False
def test_validate_threat_score():
assert validate_threat_score(85) == "high"
assert validate_threat_score(50) == "medium"
assert validate_threat_score(20) == "low"
assert validate_threat_score(-1) == "invalid"
assert validate_threat_score(101) == "invalid"
Code Example: Integration Test for API Endpoint
from fastapi.testclient import TestClient
from api.main import app
client = TestClient(app)
def test_create_threat_integration():
response = client.post(
"/api/v1/threats",
json={
"name": "SQL Injection Attempt",
"severity": "high",
"source_ip": "192.168.1.100"
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "SQL Injection Attempt"
assert data["severity"] == "high"
assert "id" in data
assert "created_at" in data
Code Example: E2E Test for Full Workflow
import requests
BASE_URL = "https://staging-api.durga-antivirus.com"
def test_threat_lifecycle_e2e():
# 1. Login
login_resp = requests.post(
f"{BASE_URL}/api/auth/login",
json={"username": "test-analyst", "password": "test-pass"}
)
assert login_resp.status_code == 200
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Create a threat
create_resp = requests.post(
f"{BASE_URL}/api/v1/threats",
json={"name": "E2E Test Threat", "severity": "medium"},
headers=headers
)
assert create_resp.status_code == 201
threat_id = create_resp.json()["id"]
# 3. Fetch the threat
get_resp = requests.get(
f"{BASE_URL}/api/v1/threats/{threat_id}",
headers=headers
)
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "E2E Test Threat"
# 4. Update severity
update_resp = requests.patch(
f"{BASE_URL}/api/v1/threats/{threat_id}",
json={"severity": "high"},
headers=headers
)
assert update_resp.status_code == 200
# 5. Delete the threat
delete_resp = requests.delete(
f"{BASE_URL}/api/v1/threats/{threat_id}",
headers=headers
)
assert delete_resp.status_code == 204
# 6. Verify deletion
verify_resp = requests.get(
f"{BASE_URL}/api/v1/threats/{threat_id}",
headers=headers
)
assert verify_resp.status_code == 404
print("E2E threat lifecycle test passed")
Common Mistakes
1. Too Many E2E Tests
E2E tests are slow (seconds to minutes each) and flaky (network issues, database state). Keep E2E tests under 10% of the test suite.
2. No Integration Tests
Unit tests mock everything. Integration tests verify that the API actually works with the database, middleware, and external services.
3. Testing Implementation Details
Test behavior, not implementation. Avoid testing private functions or internal state. Tests that break on Refactoring provide negative value.
4. Shared Test State
Tests that depend on shared database state are flaky and order-dependent. Each test should set up and tear down its own data.
5. Slow Test Suite
Developers stop running tests when they take too long. Keep unit tests under 1ms each, integration tests under 100ms, and E2E tests under 10 seconds.
Practice Questions
- What is the recommended ratio of unit to integration to E2E tests?
- Why should E2E tests be kept to a minimum?
- What is the difference between integration and E2E tests?
- How do you test database interactions in integration tests?
- When should you mock external services vs use real ones?
Answers:
- 70/20/10: 70% unit tests, 20% integration tests, 10% E2E tests. The exact ratio varies by project.
- E2E tests are slow, flaky, expensive to maintain, and difficult to debug when they fail. They test too many components at once.
- Integration tests verify that components work together (database + API). E2E tests verify the complete system including external dependencies and user workflows.
- Use test databases (in-memory SQLite, PostgreSQL test containers, or Docker databases) with fixtures that set up and tear down data per test.
- Mock external services in unit and integration tests. Use real services in dedicated E2E tests (ideally on a staging environment).
Challenge: Refactor a test suite with 50% E2E tests to follow the testing pyramid. Move E2E tests to integration tests by replacing external dependencies with test doubles.
FAQ
Mini Project
Build a test suite for a threat intelligence API following the testing pyramid: 20+ unit tests for validation and business logic, 10+ integration tests for API endpoints with test database, and 3 E2E tests for critical user workflows.
What's Next
Now learn about Unit Testing API Functions for testing individual API components in isolation.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro