Files
gh-tachyon-beep-skillpacks-…/skills/api-testing-strategies/SKILL.md
2025-11-30 08:59:43 +08:00

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.**