APIs fail in ways that are hard to anticipate from reading code. A field that's always been a string starts returning null. A response that worked with small payloads times out with large ones. An endpoint that passed unit tests fails in staging because it hits an external service with different behavior than the mock.
API testing is the practice of systematically finding these failures before your users do. When done well, it catches bugs, prevents regressions, validates contracts between services, and gives you confidence that deployments won't break production.
This guide covers what works in practice: the testing pyramid for APIs, what each layer should cover, common mistakes that create false confidence, and how to integrate testing into a CI/CD pipeline efficiently.
The API Testing Pyramid
The pyramid model applies to API testing as well as unit testing:
/\
/ \
/ E2E \ <- few, slow, high confidence
/--------\
/ Integration \ <- moderate number, medium speed
/--------------\
/ Unit Tests \ <- many, fast, narrow scope
/------------------\
Each layer has different characteristics. The goal is the right balance—not maximum coverage at the most expensive layer.
Unit Tests: Fast and Narrow
Unit tests cover individual functions in isolation:
- Request parsing and validation logic
- Response serialization
- Business logic calculations
- Error handling code paths
Mock all external dependencies. Unit tests should run in milliseconds and never touch a network or database.
# Unit test: request validation logic
def test_user_creation_requires_email():
request = CreateUserRequest(name="Alice", email=None)
with pytest.raises(ValidationError) as exc_info:
validate_create_user(request)
assert "email" in str(exc_info.value)
Integration Tests: Verify the Stack Works Together
Integration tests run against a real database and real dependencies (or close approximations):
- Database queries return correct data
- Authentication middleware correctly validates tokens
- Response format matches the documented schema
- Error responses have the right status codes and structure
# Integration test: against real database
def test_create_user_persists(db_session):
response = client.post("/api/users", json={
"name": "Alice",
"email": "alice@example.com"
})
assert response.status_code == 201
user_id = response.json()["id"]
# Verify it actually persisted
user = db_session.query(User).get(user_id)
assert user.email == "alice@example.com"
End-to-End Tests: Validate Complete Flows
E2E tests exercise the complete system:
- Authentication and authorization flows
- Multi-step workflows (create → update → delete)
- Cross-service interactions
- Webhook delivery and retry behavior
Keep this layer small—focus on critical paths, not every combination.
What to Test at Each Layer
Request Validation
Every endpoint that accepts input needs these tests:
Valid input → success response with correct data
Missing required fields → 400 with field-level errors
Invalid field types → 400 with clear error message
Boundary values → correct handling (empty string, max length, zero, negative)
Unexpected fields → either ignored or rejected per your API contract
Response Structure
Validate that responses match the documented contract:
def test_user_response_schema():
response = client.get("/api/users/123")
assert response.status_code == 200
data = response.json()
# Required fields present
assert "id" in data
assert "name" in data
assert "email" in data
assert "created_at" in data
# Correct types
assert isinstance(data["id"], int)
assert isinstance(data["name"], str)
assert isinstance(data["created_at"], str)
# No sensitive fields leaked
assert "password_hash" not in data
assert "internal_notes" not in data
Authentication and Authorization
This is one of the most commonly undertested areas.
# 401 for missing auth
def test_endpoint_requires_authentication():
response = client.get("/api/users/profile")
assert response.status_code == 401
# 401 for invalid token
def test_endpoint_rejects_invalid_token():
response = client.get(
"/api/users/profile",
headers={"Authorization": "Bearer invalid-token"}
)
assert response.status_code == 401
# 403 for insufficient permissions
def test_endpoint_requires_admin_role():
user_token = get_token(role="user")
response = client.delete(
"/api/users/456",
headers={"Authorization": f"Bearer {user_token}"}
)
assert response.status_code == 403
# Successful access with correct auth
def test_endpoint_accessible_with_valid_token():
admin_token = get_token(role="admin")
response = client.delete(
"/api/users/456",
headers={"Authorization": f"Bearer {admin_token}"}
)
assert response.status_code == 204
Also test: expired tokens, tokens for deleted users, tokens with wrong scopes, token reuse after logout.
Error Responses
Error responses are part of your API contract. They should be consistent and machine-readable.
Test that:
- Error status codes are correct (400 for client errors, 500 for server errors)
- Error bodies follow a consistent format
- Error messages are informative but don't leak stack traces or internal details
- Required fields missing from error responses don't cause secondary errors
def test_error_response_format():
response = client.post("/api/users", json={}) # missing required fields
assert response.status_code == 400
error = response.json()
assert "error" in error
assert "message" in error["error"]
assert "code" in error["error"]
assert "details" in error["error"]
# Should not leak internals
assert "traceback" not in str(error)
assert "sql" not in str(error).lower()
Contract Testing
Contract testing is one of the most underused API testing practices. It solves a specific problem: when a consumer (frontend, mobile app, another service) depends on a provider API, how do you catch breaking changes before deployment?
Consumer-Driven Contract Tests
The consumer defines a contract: "I call this endpoint with this request, and I expect this response shape." The provider runs these contracts as part of its test suite.
// Consumer defines the contract (using Pact)
const interaction = {
description: "a request for user details",
request: {
method: "GET",
path: "/api/users/123",
headers: { Authorization: "Bearer valid-token" }
},
response: {
status: 200,
body: {
id: like(123),
name: like("string"),
email: like("user@example.com"),
created_at: like("2026-01-01T00:00:00Z")
}
}
};
When the provider makes a change that breaks the contract (removes a field, changes a type, modifies status codes), the contract test fails before deployment.
This is particularly valuable for microservices and mobile app backends where the client code is harder to update than the server.
Performance Testing
Response Time Baselines
Establish acceptable response time targets and test against them:
def test_user_list_response_time():
start = time.time()
response = client.get("/api/users?page=1&limit=50")
elapsed = time.time() - start
assert response.status_code == 200
assert elapsed < 0.5 # 500ms threshold
Load Testing Basics
Tools like k6, Locust, or Artillery let you run concurrent load tests. The API rate limit calculator helps you plan your load test parameters.
Basic k6 load test:
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
vus: 50, // 50 concurrent users
duration: "30s", // run for 30 seconds
};
export default function () {
const response = http.get("https://api.example.com/users");
check(response, {
"status is 200": (r) => r.status === 200,
"response time < 500ms": (r) => r.timings.duration < 500,
});
sleep(1);
}
Run this to understand: throughput at target load, p95/p99 latency, error rate under load, where the bottlenecks are.
Testing Asynchronous APIs
Many modern APIs are asynchronous—a request starts an operation and you poll or receive a webhook when it completes. Testing these requires different approaches.
Polling Patterns
def test_async_job_completes():
# Start the job
response = client.post("/api/jobs", json={"type": "export"})
assert response.status_code == 202
job_id = response.json()["id"]
# Poll until complete or timeout
timeout = time.time() + 30 # 30 second timeout
while time.time() < timeout:
status = client.get(f"/api/jobs/{job_id}")
if status.json()["status"] in ("completed", "failed"):
break
time.sleep(0.5)
assert status.json()["status"] == "completed"
assert "result_url" in status.json()
Webhook Testing
Testing webhooks locally requires a way to receive external HTTP calls. Tools like ngrok, smee.io, or a local mock server work well.
# Mock webhook receiver for testing
class WebhookCapture:
def __init__(self):
self.received = []
def capture(self, payload):
self.received.append(payload)
def test_order_webhook_fires_on_completion(webhook_capture):
# Configure webhook endpoint to capture
client.post("/api/webhooks", json={
"event": "order.completed",
"url": webhook_capture.url
})
# Trigger the event
client.post("/api/orders/123/complete")
# Wait for webhook delivery
time.sleep(1)
assert len(webhook_capture.received) == 1
webhook_data = webhook_capture.received[0]
assert webhook_data["event"] == "order.completed"
assert webhook_data["data"]["order_id"] == 123
Using the API Tester for Manual Testing
Before writing automated tests, use the DevPlaybook API tester to explore endpoints and understand their behavior.
The browser-based tester lets you:
- Send requests with custom headers, auth tokens, and request bodies
- Inspect full response details including status, headers, and timing
- Test edge cases without writing code
- Save request configurations for reuse
This is particularly useful when:
- You're testing a third-party API before building an integration
- Debugging a specific request that's failing in production
- Exploring an undocumented or partially-documented API
The API response formatter helps when responses are large or complex—it pretty-prints, validates, and lets you navigate the structure.
Common Mistakes in API Testing
Testing Happy Paths Only
The most common API testing mistake: only testing the success case. Errors, edge cases, and boundary conditions are where bugs live.
For every endpoint, also test:
- Missing authentication
- Insufficient authorization
- Invalid input (missing fields, wrong types, out-of-range values)
- Non-existent resources (404 cases)
- Duplicate creation attempts
- Concurrent requests (for mutation endpoints)
Mocking Too Much in Integration Tests
Unit tests should mock dependencies. Integration tests should not. When you mock the database in an integration test, you lose the confidence that your queries work correctly.
This is a real risk: integration tests that mock the database passed, but production queries failed because the mock behavior didn't match the real database's behavior.
Not Testing the Contract
Tests that verify your code runs correctly don't verify that the API contract is stable. If you add a response field that consumers depend on, then remove it in a refactor, your tests might still pass while breaking consumers.
Use contract tests or at minimum test your exact response schema.
Ignoring Headers
Headers are part of the API contract. Test that:
-
Content-Type: application/jsonis set on JSON responses -
Cache-Controlheaders are correct for cacheable resources - Rate limit headers are present and accurate
- CORS headers allow the right origins
Not Testing Pagination
If your API paginates, test:
- First page returns correct data
- Last page (or empty page) returns correct metadata
- Page size limits are enforced
- Cursor/offset manipulation doesn't allow data leakage
- Total count is accurate
CI/CD Integration
Run the Testing Pyramid in Stages
# GitHub Actions example
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Run unit tests
run: pytest tests/unit/ --timeout=30
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: testpass
steps:
- name: Run integration tests
run: pytest tests/integration/ --timeout=120
contract-tests:
needs: integration-tests
runs-on: ubuntu-latest
steps:
- name: Run contract tests
run: pact-verifier --provider-base-url=http://localhost:8000
Fail Fast
Run faster, narrower tests first. Don't run integration tests if unit tests fail. Don't run performance tests if integration tests fail.
Test Data Management
Avoid shared test data between test runs. Each test should either:
- Create its own data and clean up afterward
- Use database transactions that roll back after each test
- Run against a fresh database per test run (feasible with containers)
API Documentation Testing
If you maintain OpenAPI (Swagger) documentation, test that the implementation matches the spec.
Tools like schemathesis generate tests automatically from an OpenAPI spec:
# Generate and run tests from OpenAPI spec
schemathesis run https://api.example.com/openapi.json \
--checks not_a_server_error \
--validate-schema true
This catches cases where the implementation diverges from the documentation—a common source of consumer bugs.
Checklist
Before shipping an API to production:
Request handling:
- [ ] All required fields validated
- [ ] Input types validated with clear error messages
- [ ] Boundary values handled correctly
- [ ] Unexpected fields handled (ignored or rejected)
Authentication/Authorization:
- [ ] Missing token returns 401
- [ ] Invalid token returns 401
- [ ] Expired token returns 401
- [ ] Insufficient permissions returns 403
- [ ] Each resource only accessible by its owner (if applicable)
Response contract:
- [ ] All documented fields present
- [ ] No undocumented sensitive fields in responses
- [ ] Status codes match documented behavior
- [ ] Error response format is consistent
Performance:
- [ ] Response time within defined threshold
- [ ] Load test at expected peak traffic
- [ ] No N+1 query issues
Reliability:
- [ ] Concurrent requests don't cause data corruption
- [ ] Partial failures handled gracefully
- [ ] Timeouts handled with appropriate error responses
Use the DevPlaybook API tester for exploratory testing during development, and invest in the automated test pyramid for the confidence that scales with your codebase.
Level Up Your Dev Workflow
Found this useful? Explore DevPlaybook — cheat sheets, tool comparisons, and hands-on guides for modern developers.
🛒 Get the DevToolkit Starter Kit on Gumroad — 40+ browser-based dev tools, source code + deployment guide included.
Top comments (0)