API Testing Strategies: From Unit Tests to Security Scans
API Testing Strategies#
APIs are the contracts between systems. When they break, everything downstream breaks. A layered testing strategy catches failures at every level — from individual handlers to production traffic patterns.
The Testing Pyramid for APIs#
/ E2E \ Few, slow, expensive
/ Contract \ Medium count, fast
/ Integration \ Per-service, moderate
/ Unit Tests \ Many, fast, cheap
Each layer catches different classes of bugs. Skipping a layer creates blind spots.
Unit Testing Endpoints#
Unit tests validate handler logic in isolation — no database, no network, no external services.
// Express handler
export function getUser(req: Request, res: Response) {
const user = req.userService.findById(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
return res.status(200).json(user);
}
// Unit test
describe("getUser", () => {
it("returns 404 when user not found", () => {
const req = { params: { id: "123" }, userService: { findById: () => null } };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
getUser(req as any, res as any);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: "Not found" });
});
});
What Unit Tests Catch#
- Incorrect status codes
- Missing error handling
- Wrong response shapes
- Business logic bugs in handlers
What They Miss#
- Database query correctness
- Middleware behavior
- Serialization issues
- Actual HTTP parsing
Integration Tests#
Integration tests hit the running server with real HTTP requests and a real (or test) database.
import supertest from "supertest";
import { app } from "../app";
import { seedDatabase, cleanDatabase } from "./helpers";
describe("GET /api/users/:id", () => {
beforeEach(async () => await seedDatabase());
afterEach(async () => await cleanDatabase());
it("returns the user with correct shape", async () => {
const res = await supertest(app).get("/api/users/1").expect(200);
expect(res.body).toMatchObject({
id: 1,
name: expect.any(String),
email: expect.stringContaining("@"),
});
});
it("returns 401 without auth header", async () => {
await supertest(app)
.get("/api/users/1")
.unset("Authorization")
.expect(401);
});
});
Integration Test Best Practices#
- Use a dedicated test database (never share with development)
- Seed and tear down per test or per suite
- Test authentication and authorization paths
- Validate response headers, not just bodies
- Test pagination, filtering, and sorting
Contract Testing#
Contract tests verify that a provider API matches what consumers expect, without requiring both to run simultaneously.
Pact (Consumer-Driven Contracts)#
The consumer defines expectations. The provider verifies against them.
// Consumer side — defines the contract
const interaction = {
state: "user 1 exists",
uponReceiving: "a request for user 1",
withRequest: {
method: "GET",
path: "/api/users/1",
headers: { Accept: "application/json" },
},
willRespondWith: {
status: 200,
headers: { "Content-Type": "application/json" },
body: {
id: like(1),
name: like("Jane Doe"),
email: like("jane@example.com"),
},
},
};
# Provider side — verifies the contract
npx pact-provider-verifier \
--provider-base-url http://localhost:3000 \
--pact-urls ./pacts/consumer-provider.json
Dredd (API-Description Contracts)#
Dredd tests your API against an OpenAPI or API Blueprint spec.
# Validate implementation matches the spec
dredd openapi.yaml http://localhost:3000
Dredd catches:
- Missing endpoints defined in the spec
- Wrong status codes or response shapes
- Undocumented endpoints (with
--namesflag)
When to Use Which#
| Scenario | Tool |
|---|---|
| Multiple consumers depend on one API | Pact |
| API-first design with OpenAPI spec | Dredd |
| Internal microservice communication | Pact |
| Public API with published docs | Dredd |
Load Testing#
Load tests reveal how your API behaves under pressure — latency spikes, error rates, resource exhaustion.
k6#
// load-test.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "30s", target: 50 }, // ramp up
{ duration: "1m", target: 50 }, // sustained
{ duration: "10s", target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ["p(95)<500"], // 95th percentile under 500ms
http_req_failed: ["rate<0.01"], // less than 1% errors
},
};
export default function () {
const res = http.get("https://api.example.com/users");
check(res, {
"status is 200": (r) => r.status === 200,
"response time OK": (r) => r.timings.duration < 500,
});
sleep(1);
}
Artillery#
# artillery-config.yaml
config:
target: "https://api.example.com"
phases:
- duration: 60
arrivalRate: 20
name: "Sustained load"
ensure:
p95: 500
maxErrorRate: 1
scenarios:
- name: "Browse users"
flow:
- get:
url: "/api/users"
expect:
- statusCode: 200
- think: 1
- get:
url: "/api/users/{{ $randomNumber(1, 1000) }}"
Key Metrics to Watch#
- p50/p95/p99 latency — averages hide tail latency
- Error rate under load
- Throughput (requests per second)
- Resource saturation (CPU, memory, connections)
Security Testing#
OWASP ZAP#
ZAP scans your API for common vulnerabilities — injection, broken auth, misconfigurations.
# Passive scan against OpenAPI spec
zap-cli quick-scan --self-contained \
--start-options "-config api.addrs.addr.name=.* -config api.addrs.addr.regex=true" \
-l Informational \
http://localhost:3000
# Active scan (more aggressive)
zap-cli active-scan http://localhost:3000
Common API Security Issues ZAP Detects#
- SQL injection via query parameters
- Missing security headers (CORS, CSP, HSTS)
- Broken authentication endpoints
- Information leakage in error responses
- Missing rate limiting
Security Testing Checklist#
- Authentication — test expired tokens, invalid tokens, missing tokens
- Authorization — test accessing other users' resources (BOLA/IDOR)
- Input validation — oversized payloads, unexpected types, special characters
- Rate limiting — verify limits exist and are enforced
- Error responses — ensure stack traces and internal details are hidden
API Mocking#
Mocks let consumers develop and test without a running provider.
MSW (Mock Service Worker)#
MSW intercepts network requests at the service worker level — works in browser and Node.
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Mock User",
email: "mock@example.com",
});
}),
];
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
WireMock#
WireMock runs as a standalone server — language-agnostic, good for integration environments.
{
"request": {
"method": "GET",
"urlPathPattern": "/api/users/[0-9]+"
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": {
"id": 1,
"name": "Stubbed User"
}
}
}
# Start WireMock with mappings
docker run -p 8080:8080 -v ./mappings:/home/wiremock/mappings wiremock/wiremock
Test Automation Pipeline#
# CI pipeline stages
stages:
- unit-tests: # seconds — runs on every commit
run: npm test
- integration-tests: # minutes — runs on every PR
services: [postgres, redis]
run: npm run test:integration
- contract-tests: # minutes — runs on provider changes
run: npx pact-provider-verifier ...
- security-scan: # minutes — runs nightly or on release
run: zap-cli quick-scan ...
- load-tests: # minutes — runs pre-release
run: k6 run load-test.js
Automation Best Practices#
- Run unit and integration tests on every pull request
- Run contract tests when either consumer or provider changes
- Run security scans nightly and before releases
- Run load tests before major releases and after infrastructure changes
- Store test results as artifacts for trend analysis
- Set quality gates — fail the build if thresholds are breached
Wrapping Up#
No single testing strategy covers all failure modes. Unit tests catch logic bugs fast. Integration tests verify real behavior. Contract tests prevent breaking changes across services. Load tests expose capacity limits. Security tests find vulnerabilities before attackers do. Layer them, automate them, and fail builds when thresholds break.
Article #367 -- Codelit has mass-produced 368 articles to date. Explore them at codelit.io.
Try it on Codelit
GitHub Integration
Paste a repo URL and generate architecture from your actual codebase
Related articles
API Backward Compatibility: Ship Changes Without Breaking Consumers
6 min read
api designBatch API Endpoints — Patterns for Bulk Operations, Partial Success, and Idempotency
8 min read
system designCircuit Breaker Implementation — State Machine, Failure Counting, Fallbacks, and Resilience4j
7 min read
Try these templates
OpenAI API Request Pipeline
7-stage pipeline from API call to token generation, handling millions of requests per minute.
8 componentsDistributed Rate Limiter
API rate limiting with sliding window, token bucket, and per-user quotas.
7 componentsMultiplayer Game Backend
Real-time multiplayer game server with matchmaking, state sync, leaderboards, and anti-cheat.
8 componentsBuild this architecture
Generate an interactive architecture for API Testing Strategies in seconds.
Try it in Codelit →
Comments