472 lines
11 KiB
Markdown
472 lines
11 KiB
Markdown
---
|
|
name: api-testing-strategies
|
|
description: 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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.**
|
|
|
|
```python
|
|
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.**
|
|
|
|
```python
|
|
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.**
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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.**
|
|
|
|
```python
|
|
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.**
|
|
|
|
```python
|
|
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.**
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
# ❌ 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
|
|
|
|
```python
|
|
# ✅ 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
|
|
|
|
```python
|
|
# ❌ 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
|
|
|
|
```python
|
|
# ✅ 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
|
|
|
|
```python
|
|
# ❌ 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
|
|
|
|
```python
|
|
# ❌ BAD: Hardcoded production URL
|
|
def test_api():
|
|
response = requests.get("https://api.production.com/users")
|
|
```
|
|
|
|
**Fix:** Use environment variables or fixtures
|
|
|
|
```python
|
|
# ✅ 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:**
|
|
|
|
```python
|
|
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:**
|
|
|
|
```python
|
|
# 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:**
|
|
```bash
|
|
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:**
|
|
|
|
```yaml
|
|
# .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.**
|