11 KiB
name, description
| name | description |
|---|---|
| api-testing-strategies | Use when testing REST/GraphQL APIs, designing API test suites, validating request/response contracts, testing authentication/authorization, handling API versioning, or choosing API testing tools - provides test pyramid placement, schema validation, and anti-patterns distinct from E2E browser testing |
API Testing Strategies
Overview
Core principle: API tests sit between unit tests and E2E tests - faster than browser tests, more realistic than mocks.
Rule: Test APIs directly via HTTP/GraphQL, not through the UI. Browser tests are 10x slower and more flaky.
API Testing vs E2E Testing
| Aspect | API Testing | E2E Browser Testing |
|---|---|---|
| Speed | Fast (10-100ms per test) | Slow (1-10s per test) |
| Flakiness | Low (no browser/JS) | High (timing, rendering) |
| Coverage | Business logic, data | Full user workflow |
| Tools | REST Client, Postman, pytest | Playwright, Cypress |
| When to use | Most backend testing | Critical user flows only |
Test Pyramid placement:
- Unit tests (70%): Individual functions/classes
- API tests (20%): Endpoints, business logic, integrations
- E2E tests (10%): Critical user workflows through browser
Tool Selection Decision Tree
| Your Stack | Team Skills | Use | Why |
|---|---|---|---|
| Python backend | pytest familiar | pytest + requests | Best integration, fixtures |
| Node.js/JavaScript | Jest/Mocha | supertest | Express/Fastify native |
| Any language, REST | Prefer GUI | Postman + Newman | GUI for design, CLI for CI |
| GraphQL | Any | pytest + gql (Python) or apollo-client (JS) | Query validation |
| Contract testing | Microservices | Pact | Consumer-driven contracts |
First choice: Use your existing test framework (pytest/Jest) + HTTP client. Don't add new tools unnecessarily.
Test Structure Pattern
Basic REST API Test
import pytest
import requests
@pytest.fixture
def api_client():
"""Base API client with auth."""
return requests.Session()
def test_create_order(api_client):
# Arrange: Set up test data
payload = {
"user_id": 123,
"items": [{"sku": "WIDGET", "quantity": 2}],
"shipping_address": "123 Main St"
}
# Act: Make API call
response = api_client.post(
"https://api.example.com/orders",
json=payload,
headers={"Authorization": "Bearer test_token"}
)
# Assert: Validate response
assert response.status_code == 201
data = response.json()
assert data["id"] is not None
assert data["status"] == "pending"
assert data["total"] > 0
GraphQL API Test
from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport
def test_user_query():
transport = RequestsHTTPTransport(url="https://api.example.com/graphql")
client = Client(transport=transport)
query = gql('''
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
''')
result = client.execute(query, variable_values={"id": "123"})
assert result["user"]["id"] == "123"
assert result["user"]["email"] is not None
What to Test
1. Happy Path (Required)
Test successful requests with valid data.
def test_get_user_success():
response = api.get("/users/123")
assert response.status_code == 200
assert response.json()["name"] == "Alice"
2. Validation Errors (Required)
Test API rejects invalid input.
def test_create_user_invalid_email():
response = api.post("/users", json={"email": "invalid"})
assert response.status_code == 400
assert "email" in response.json()["errors"]
3. Authentication & Authorization (Required)
Test auth failures.
def test_unauthorized_without_token():
response = api.get("/orders", headers={}) # No auth token
assert response.status_code == 401
def test_forbidden_different_user():
response = api.get(
"/orders/999",
headers={"Authorization": "Bearer user_123_token"}
)
assert response.status_code == 403 # Can't access other user's orders
4. Edge Cases (Important)
def test_pagination_last_page():
response = api.get("/users?page=999")
assert response.status_code == 200
assert response.json()["results"] == []
def test_large_payload():
items = [{"sku": f"ITEM_{i}", "quantity": 1} for i in range(1000)]
response = api.post("/orders", json={"items": items})
assert response.status_code in [201, 413] # Created or payload too large
5. Idempotency (For POST/PUT/DELETE)
Test same request twice produces same result.
def test_create_user_idempotent():
payload = {"email": "alice@example.com", "name": "Alice"}
# First request
response1 = api.post("/users", json=payload)
user_id_1 = response1.json()["id"]
# Second identical request
response2 = api.post("/users", json=payload)
# Should return existing user, not create duplicate
assert response2.status_code in [200, 409] # OK or Conflict
if response2.status_code == 200:
assert response2.json()["id"] == user_id_1
Schema Validation
Use JSON Schema to validate response structure.
import jsonschema
USER_SCHEMA = {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string", "format": "email"}
},
"required": ["id", "name", "email"]
}
def test_user_response_schema():
response = api.get("/users/123")
data = response.json()
jsonschema.validate(instance=data, schema=USER_SCHEMA) # Raises if invalid
Why it matters: Prevents regressions where fields are removed or types change.
API Versioning Tests
Test multiple API versions simultaneously.
@pytest.mark.parametrize("version,expected_fields", [
("v1", ["id", "name"]),
("v2", ["id", "name", "email", "created_at"]),
])
def test_user_endpoint_version(version, expected_fields):
response = api.get(f"/{version}/users/123")
data = response.json()
for field in expected_fields:
assert field in data
Anti-Patterns Catalog
❌ Testing Through the UI
Symptom: Using browser automation to test API functionality
# ❌ BAD: Testing API via browser
def test_create_order():
page.goto("/orders/new")
page.fill("#item", "Widget")
page.click("#submit")
assert page.locator(".success").is_visible()
Why bad:
- 10x slower than API test
- Flaky (browser timing issues)
- Couples API test to UI changes
Fix: Test API directly
# ✅ GOOD: Direct API test
def test_create_order():
response = api.post("/orders", json={"item": "Widget"})
assert response.status_code == 201
❌ Testing Implementation Details
Symptom: Asserting on database queries, internal logic
# ❌ BAD: Testing implementation
def test_get_user():
with patch('database.execute') as mock_db:
api.get("/users/123")
assert mock_db.called_with("SELECT * FROM users WHERE id = 123")
Why bad: Couples test to implementation, not contract
Fix: Test only request/response contract
# ✅ GOOD: Test contract only
def test_get_user():
response = api.get("/users/123")
assert response.status_code == 200
assert response.json()["id"] == 123
❌ No Test Data Isolation
Symptom: Tests interfere with each other
# ❌ BAD: Shared test data
def test_update_user():
api.put("/users/123", json={"name": "Bob"})
assert api.get("/users/123").json()["name"] == "Bob"
def test_get_user():
# Fails if previous test ran!
assert api.get("/users/123").json()["name"] == "Alice"
Fix: Each test creates/cleans its own data (see test-isolation-fundamentals skill)
❌ Hardcoded URLs and Tokens
Symptom: Production URLs or real credentials in tests
# ❌ BAD: Hardcoded production URL
def test_api():
response = requests.get("https://api.production.com/users")
Fix: Use environment variables or fixtures
# ✅ GOOD: Configurable environment
import os
@pytest.fixture
def api_base_url():
return os.getenv("API_URL", "http://localhost:8000")
def test_api(api_base_url):
response = requests.get(f"{api_base_url}/users")
Mocking External APIs
When testing service A that calls service B:
import responses
@responses.activate
def test_payment_success():
# Mock Stripe API
responses.add(
responses.POST,
"https://api.stripe.com/v1/charges",
json={"id": "ch_123", "status": "succeeded"},
status=200
)
# Test your API
response = api.post("/checkout", json={"amount": 1000})
assert response.status_code == 200
assert response.json()["payment_status"] == "succeeded"
When to mock:
- External service costs money (Stripe, Twilio)
- External service is slow
- External service is unreliable
- Testing error handling (simulate failures)
When NOT to mock:
- Integration tests (use separate test suite with real services)
- Contract tests (use Pact to verify integration)
Performance Testing APIs
Use load testing for APIs separately from E2E:
# locust load test
from locust import HttpUser, task, between
class APIUser(HttpUser):
wait_time = between(1, 3)
@task
def get_users(self):
self.client.get("/users")
@task(3) # 3x more frequent
def get_user(self):
self.client.get("/users/123")
Run with:
locust -f locustfile.py --headless -u 100 -r 10 --run-time 60s
See load-testing-patterns skill for comprehensive guidance.
CI/CD Integration
API tests should run on every commit:
# .github/workflows/api-tests.yml
name: API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run API tests
run: |
pytest tests/api/ -v
env:
API_URL: http://localhost:8000
API_TOKEN: ${{ secrets.TEST_API_TOKEN }}
Test stages:
- Commit: Smoke tests (5-10 critical endpoints, <1 min)
- PR: Full API suite (all endpoints, <5 min)
- Merge: API + integration tests (<15 min)
Quick Reference: API Test Checklist
For each endpoint, test:
- Happy path (valid request → 200/201)
- Validation (invalid input → 400)
- Authentication (no token → 401)
- Authorization (wrong user → 403)
- Not found (missing resource → 404)
- Idempotency (duplicate request → same result)
- Schema (response matches expected structure)
- Edge cases (empty lists, large payloads, pagination)
Bottom Line
API tests are faster, more reliable, and provide better coverage than E2E browser tests for backend logic.
- Test APIs directly, not through the browser
- Use your existing test framework (pytest/Jest) + HTTP client
- Validate schemas to catch breaking changes
- Mock external services to avoid flakiness and cost
- Run API tests on every commit (they're fast enough)
If you're using browser automation to test API functionality, you're doing it wrong. Test APIs directly.