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

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.