TL;DR
API testing spans multiple layers: manual exploration with cURL and Postman, automated integration tests with supertest (Node.js) or httpx + pytest (Python), mock servers with MSW or json-server for frontend development, and load testing with k6 or Artillery. Document your API with OpenAPI 3.1 and enforce contracts with Pact. Convert any cURL command to code in your language with our cURL to code converter.
HTTP Methods and Status Codes — The Foundation of API Testing
Understanding HTTP semantics is the prerequisite for effective API testing. Every test makes assumptions about idempotency, safety, and expected status codes.
HTTP Method Semantics
| Method | Purpose | Idempotent? | Safe? | Typical Success Status |
|---|---|---|---|---|
GET | Retrieve resource | Yes | Yes | 200 OK |
POST | Create resource or trigger action | No | No | 201 Created |
PUT | Replace full resource | Yes | No | 200 OK or 204 |
PATCH | Partial update | Depends | No | 200 OK or 204 |
DELETE | Remove resource | Yes | No | 204 No Content |
HTTP Status Code Reference
| Code | Name | When to Use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH |
201 | Created | Successful POST that created a resource; include Location header |
204 | No Content | Successful DELETE or PUT/PATCH with no response body |
400 | Bad Request | Invalid input, malformed JSON, missing required fields |
401 | Unauthorized | Missing or invalid authentication credentials |
403 | Forbidden | Authenticated but lacks permission for this action |
404 | Not Found | Resource does not exist |
409 | Conflict | Duplicate resource, concurrent modification conflict |
422 | Unprocessable Entity | Validation errors (preferred over 400 for validation) |
429 | Too Many Requests | Rate limit exceeded; include Retry-After header |
500 | Internal Server Error | Unexpected server error; log it, never expose details |
503 | Service Unavailable | Server overloaded or down for maintenance |
cURL API Testing — Essential Flags and Patterns
curl is the universal API testing tool available on every platform. Mastering its flags makes ad-hoc API exploration and CI debugging much faster.
# ─── Basic request flags ─────────────────────────────────────────────────────
curl https://api.example.com/users # GET (default)
curl -X POST https://api.example.com/users # -X method
curl -v https://api.example.com/users # -v verbose (shows headers)
curl -I https://api.example.com/users # -I HEAD only (headers)
curl -L https://example.com/redirect # -L follow redirects
# ─── Headers and authentication ──────────────────────────────────────────────
curl -H "Authorization: Bearer TOKEN" https://api.example.com/me
curl -H "X-API-Key: mykey" -H "Accept: application/json" https://api.example.com/data
curl -u username:password https://api.example.com/basic-auth # Basic auth
curl --oauth2-bearer TOKEN https://api.example.com/oauth # OAuth2
# ─── Sending request body ────────────────────────────────────────────────────
# JSON body (modern curl 7.82+)
curl --json '{"name":"Alice","email":"alice@example.com"}' https://api.example.com/users
# JSON body (older curl)
curl -X POST -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}' https://api.example.com/users
# Form data (application/x-www-form-urlencoded)
curl -X POST -d "name=Alice&email=alice@example.com" https://api.example.com/users
# Multipart form (file upload)
curl -X POST -F "file=@photo.jpg" -F "userId=123" https://api.example.com/upload
# ─── Output and inspection ───────────────────────────────────────────────────
curl -s https://api.example.com/users | jq '.' # pretty-print JSON
curl -s https://api.example.com/users | jq '.[0].email' # extract field
curl -w "
Status: %{http_code}
Time: %{time_total}s
" https://api.example.com/users -o /dev/null # status + timing only
# ─── Save and replay ─────────────────────────────────────────────────────────
curl -c cookies.txt -b cookies.txt https://api.example.com/login # cookie jar
curl -o response.json https://api.example.com/data # save bodyThe -v flag is essential for debugging — it shows the full request and response including TLS handshake, request headers, response headers, and body. Pipe the output through grep -i "content-type\|status" to focus on specific headers.
JavaScript fetch and axios — Error Handling, Interceptors, and Retry
// ─── fetch with proper error handling ───────────────────────────────────────
// NOTE: fetch() only rejects on network failure — 4xx/5xx are NOT errors!
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000); // 10s timeout
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
...options?.headers,
},
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new ApiError(response.status, response.statusText, errorBody);
}
// 204 No Content has no body
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Request timed out after 10 seconds');
}
throw err;
}
}
class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public body: unknown
) {
super(`HTTP ${status}: ${statusText}`);
}
}
// Usage
const user = await apiFetch<User>('https://api.example.com/me');Axios with Interceptors and Automatic Retry
import axios, { AxiosInstance, AxiosError } from 'axios';
// Create a configured axios instance
const api: AxiosInstance = axios.create({
baseURL: 'https://api.example.com',
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});
// ─── Request interceptor: attach auth token ───────────────────────────────────
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// ─── Response interceptor: handle 401 refresh + retry ────────────────────────
let isRefreshing = false;
let failedQueue: Array<{ resolve: (v: unknown) => void; reject: (e: unknown) => void }> = [];
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => api(originalRequest));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/auth/refresh');
localStorage.setItem('accessToken', data.accessToken);
failedQueue.forEach(({ resolve }) => resolve(null));
failedQueue = [];
return api(originalRequest);
} catch (refreshError) {
failedQueue.forEach(({ reject }) => reject(refreshError));
failedQueue = [];
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;Postman and Insomnia — Collections, Tests, and Newman CLI
Postman and Insomnia are GUI API clients that add collection organization, environment variables, pre-request scripts, and test assertions on top of raw HTTP requests.
Postman Test Scripts (JavaScript)
// Postman "Tests" tab — runs after each response
// Access: pm.response, pm.environment, pm.globals, pm.collectionVariables
// ─── Status code assertions ───────────────────────────────────────────────────
pm.test("Status code is 200", () => {
pm.response.to.have.status(200);
});
pm.test("Response time is under 500ms", () => {
pm.expect(pm.response.responseTime).to.be.below(500);
});
// ─── Response body assertions ──────────────────────────────────────────────────
pm.test("Response has required fields", () => {
const json = pm.response.json();
pm.expect(json).to.have.property('id');
pm.expect(json.email).to.be.a('string').and.include('@');
pm.expect(json.role).to.be.oneOf(['admin', 'user', 'editor']);
});
// ─── Extract and save to environment ─────────────────────────────────────────
pm.test("Save access token", () => {
const json = pm.response.json();
pm.environment.set("accessToken", json.accessToken);
pm.environment.set("userId", json.user.id);
});
// ─── Pre-request script (runs before sending) ─────────────────────────────────
// Add HMAC signature to request
const timestamp = Date.now().toString();
const signature = CryptoJS.HmacSHA256(
timestamp + pm.environment.get("apiSecret"),
pm.environment.get("apiKey")
).toString();
pm.request.headers.add({ key: "X-Timestamp", value: timestamp });
pm.request.headers.add({ key: "X-Signature", value: signature });Newman CLI — Run Postman Collections in CI
npm install -g newman newman-reporter-htmlextra
# Run a collection with environment file
newman run MyAPI.postman_collection.json -e production.postman_environment.json --reporters cli,htmlextra --reporter-htmlextra-export reports/api-test-report.html
# Run with iteration data (data-driven testing)
newman run MyAPI.postman_collection.json --iteration-data test-data.json --iteration-count 5
# GitHub Actions integration
# .github/workflows/api-tests.yml
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install -g newman
- run: newman run collection.json -e env.json --bailNode.js supertest — Integration Testing Express APIs
supertest fires real HTTP requests against your Express app without starting a server, making it the standard choice for Node.js API integration tests.
npm install --save-dev supertest @types/supertest vitest// tests/users.test.ts
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import app from '../src/app'; // your Express app (not .listen())
import { db } from '../src/db';
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run(); // seed test data
});
afterAll(async () => {
await db.destroy();
});
describe('GET /users', () => {
it('returns 200 with user list', async () => {
const res = await request(app)
.get('/users')
.set('Authorization', 'Bearer ' + TEST_TOKEN)
.expect(200)
.expect('Content-Type', /json/);
expect(res.body).toBeInstanceOf(Array);
expect(res.body.length).toBeGreaterThan(0);
expect(res.body[0]).toMatchObject({
id: expect.any(String),
email: expect.stringContaining('@'),
});
});
it('returns 401 without token', async () => {
await request(app).get('/users').expect(401);
});
});
describe('POST /users', () => {
it('creates a new user and returns 201', async () => {
const newUser = { name: 'Bob', email: 'bob@example.com', role: 'user' };
const res = await request(app)
.post('/users')
.set('Authorization', 'Bearer ' + ADMIN_TOKEN)
.send(newUser)
.expect(201);
expect(res.body.id).toBeDefined();
expect(res.body.email).toBe(newUser.email);
expect(res.headers.location).toMatch(//users/.+/);
});
it('returns 422 for invalid email', async () => {
const res = await request(app)
.post('/users')
.set('Authorization', 'Bearer ' + ADMIN_TOKEN)
.send({ name: 'Bad', email: 'not-an-email', role: 'user' })
.expect(422);
expect(res.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' })
);
});
it('returns 409 for duplicate email', async () => {
await request(app)
.post('/users')
.set('Authorization', 'Bearer ' + ADMIN_TOKEN)
.send({ name: 'Alice', email: 'alice@example.com', role: 'user' })
.expect(409);
});
});Python httpx and pytest — Async API Testing
pip install httpx pytest pytest-asyncio respx# tests/test_api.py
import pytest
import httpx
from httpx import AsyncClient
BASE_URL = "https://api.example.com"
# ─── Synchronous client ────────────────────────────────────────────────────────
def test_get_users():
with httpx.Client(base_url=BASE_URL, timeout=10.0) as client:
response = client.get("/users", headers={"Authorization": f"Bearer {TOKEN}"})
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) > 0
# ─── Async client ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_user():
async with AsyncClient(base_url=BASE_URL) as client:
response = await client.post(
"/users",
json={"name": "Alice", "email": "alice@example.com"},
headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert data["email"] == "alice@example.com"
# ─── Fixtures for shared setup ────────────────────────────────────────────────
@pytest.fixture
def auth_client():
with httpx.Client(
base_url=BASE_URL,
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=10.0,
) as client:
yield client
def test_get_profile(auth_client):
response = auth_client.get("/me")
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
# ─── Mocking with respx ───────────────────────────────────────────────────────
import respx
from httpx import Response
@respx.mock
def test_external_api_call():
respx.get("https://external-api.com/data").mock(
return_value=Response(200, json={"value": 42})
)
with httpx.Client() as client:
response = client.get("https://external-api.com/data")
assert response.json()["value"] == 42
assert respx.calls.call_count == 1Mock API Servers — json-server, MSW, Mockoon, WireMock
Mock servers let frontend developers work independently from the backend, enable testing without real external dependencies, and reproduce edge cases on demand.
json-server — Zero-Config REST Mock
npm install -g json-server
# db.json
{
"users": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"posts": []
}
# Start on port 3001
json-server --watch db.json --port 3001
# Automatically supports:
# GET /users → list all
# GET /users/1 → get by id
# POST /users → create (auto-assigns id)
# PUT /users/1 → replace
# PATCH /users/1 → partial update
# DELETE /users/1 → delete
# GET /users?name=Alice → filter
# GET /users?_sort=name&_order=asc → sort
# GET /users?_page=1&_limit=10 → paginateMSW (Mock Service Worker) — Intercept at Network Level
npm install msw --save-dev
npx msw init public/ # generate service worker file
# src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
]);
}),
http.post('https://api.example.com/users', async ({ request }) => {
const body = await request.json() as { name: string; email: string };
return HttpResponse.json(
{ id: crypto.randomUUID(), ...body },
{ status: 201 }
);
}),
// Simulate error states
http.get('https://api.example.com/error-endpoint', () => {
return HttpResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}),
];
// src/mocks/browser.ts (for browser)
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// src/mocks/server.ts (for Node.js/Vitest)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());API Contract Testing — Pact, OpenAPI Validation, and schemathesis
Contract tests verify that API consumers and providers agree on the API interface, enabling independent deployment without integration environment dependencies.
Pact Consumer-Driven Contract Testing (JavaScript)
npm install @pact-foundation/pact --save-dev
// consumer.pact.test.ts — run on the consumer (frontend) side
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { fetchUser } from '../src/api/users';
import path from 'path';
const { like, string } = MatchersV3;
const provider = new PactV3({
consumer: 'frontend-app',
provider: 'user-api',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('User API contract', () => {
it('fetches a user by ID', () => {
return provider.addInteraction({
states: [{ description: 'user 123 exists' }],
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: { Accept: 'application/json' },
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: like('123'),
name: like('Alice'),
email: string(),
},
},
}).executeTest(async (mockServer) => {
const user = await fetchUser('123', mockServer.url);
expect(user.id).toBeDefined();
expect(user.email).toBeDefined();
});
});
});
// Pact writes a pact file to /pacts/frontend-app-user-api.json
// Provider side runs: pact-provider-verifier verifying against this fileOpenAPI Spec Validation with schemathesis
pip install schemathesis
# Automatically generate and run tests from OpenAPI spec
schemathesis run https://api.example.com/openapi.json --checks all --auth "Bearer TOKEN" --base-url https://api.example.com --hypothesis-max-examples 100
# schemathesis generates random valid (and invalid) inputs for every endpoint
# and checks for: 5xx responses, schema validation failures,
# response content-type mismatches, and more
# Run against local spec file
schemathesis run ./openapi.yaml --base-url http://localhost:3000Load Testing — k6, Artillery, and Apache Bench
Load tests validate that your API meets performance SLAs under realistic and peak traffic conditions.
k6 Load Test Script
// k6-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('error_rate');
const responseTime = new Trend('response_time');
export const options = {
stages: [
{ duration: '30s', target: 10 }, // ramp up to 10 VUs
{ duration: '1m', target: 50 }, // ramp up to 50 VUs
{ duration: '2m', target: 50 }, // hold at 50 VUs
{ duration: '30s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th %ile < 500ms
http_req_failed: ['rate<0.01'], // error rate < 1%
error_rate: ['rate<0.05'],
},
};
const BASE_URL = 'https://api.example.com';
const TOKEN = __ENV.API_TOKEN;
export function setup() {
// Runs once before load — e.g., create test user
const res = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
email: 'loadtest@example.com',
password: 'testpass',
}), { headers: { 'Content-Type': 'application/json' } });
return { token: res.json('accessToken') };
}
export default function (data) {
const headers = {
'Authorization': `Bearer ${data.token}`,
'Content-Type': 'application/json',
};
// Test 1: List users
const listRes = http.get(`${BASE_URL}/users`, { headers });
check(listRes, {
'list: status 200': (r) => r.status === 200,
'list: has data': (r) => r.json('length') > 0,
});
errorRate.add(listRes.status !== 200);
responseTime.add(listRes.timings.duration);
sleep(1); // think time between requests
// Test 2: Create user
const createRes = http.post(`${BASE_URL}/users`, JSON.stringify({
name: 'Load Test User',
email: `loadtest+${Date.now()}@example.com`,
}), { headers });
check(createRes, { 'create: status 201': (r) => r.status === 201 });
sleep(0.5);
}
// Run: k6 run k6-test.js -e API_TOKEN=mytokenArtillery YAML Config and Apache Bench
# artillery-config.yml
config:
target: 'https://api.example.com'
phases:
- duration: 60
arrivalRate: 10 # 10 new users per second for 60s
- duration: 120
arrivalRate: 50 # ramp to 50/s for 2 minutes
defaults:
headers:
Authorization: 'Bearer {{ $processEnvironment.API_TOKEN }}'
Content-Type: 'application/json'
scenarios:
- name: "API smoke test"
flow:
- get:
url: '/users'
expect:
- statusCode: 200
- post:
url: '/users'
json:
name: '{{ $randomString(8) }}'
email: '{{ $randomString(8) }}@test.com'
expect:
- statusCode: 201
# Run: artillery run artillery-config.yml
# ─── Apache Bench (ab) — quick smoke test ────────────────────────────────────
# -n total requests, -c concurrent requests
ab -n 1000 -c 50 -H "Authorization: Bearer TOKEN" https://api.example.com/users
# ─── wrk — modern high-performance benchmarking ──────────────────────────────
# -t threads, -c connections, -d duration
wrk -t4 -c100 -d30s https://api.example.com/usersAPI Documentation — OpenAPI 3.1, Swagger UI, and JSDoc
Good API documentation is testable documentation. An OpenAPI spec serves as both human-readable docs and machine-readable contract for validation, mocking, and SDK generation.
OpenAPI 3.1 Spec Structure
# openapi.yaml
openapi: '3.1.0'
info:
title: My API
version: '1.0.0'
description: 'A sample API demonstrating OpenAPI 3.1 structure'
contact:
email: api@example.com
servers:
- url: https://api.example.com
description: Production
- url: http://localhost:3000
description: Local development
security:
- bearerAuth: []
paths:
/users:
get:
summary: List all users
operationId: listUsers
tags: [Users]
parameters:
- name: limit
in: query
schema: { type: integer, default: 20, maximum: 100 }
- name: email
in: query
schema: { type: string, format: email }
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/User' }
'401':
$ref: '#/components/responses/Unauthorized'
post:
summary: Create a user
operationId: createUser
tags: [Users]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateUserInput' }
responses:
'201':
description: User created
headers:
Location:
schema: { type: string, example: '/users/123' }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'422':
$ref: '#/components/responses/ValidationError'
components:
schemas:
User:
type: object
required: [id, email, name, createdAt]
properties:
id: { type: string, format: uuid }
email: { type: string, format: email }
name: { type: string }
role: { type: string, enum: [admin, user, editor] }
createdAt: { type: string, format: date-time }
CreateUserInput:
type: object
required: [email, name]
properties:
email: { type: string, format: email }
name: { type: string, minLength: 2, maxLength: 100 }
role: { type: string, enum: [user, editor], default: user }
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
type: object
properties:
error: { type: string }
ValidationError:
description: Validation failed
content:
application/json:
schema:
type: object
properties:
errors:
type: array
items:
type: object
properties:
field: { type: string }
message: { type: string }Swagger UI and Redoc Setup
# Express: serve Swagger UI from your API
npm install swagger-ui-express yaml
import swaggerUi from 'swagger-ui-express';
import { readFileSync } from 'fs';
import YAML from 'yaml';
const spec = YAML.parse(readFileSync('./openapi.yaml', 'utf8'));
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
// → http://localhost:3000/docs
# FastAPI auto-generates OpenAPI + Swagger UI at /docs
# (no configuration needed — decorators define the schema)
# Redoc (clean read-only documentation)
npx @redocly/cli preview-docs openapi.yaml
# Or serve statically:
npx @redocly/cli build-docs openapi.yaml --output docs/index.htmlConvert any cURL command to code — paste a curl command into our cURL to code converter to get equivalent JavaScript fetch, axios, Python httpx, Go, or Ruby code instantly.
Key Takeaways
- GET is safe and idempotent; POST is neither. Always return 201 with a Location header after successful POST creation.
- cURL -v shows everything: use it with
grep -ito isolate specific headers during debugging. Pipe output tojqfor JSON formatting. - fetch() does not throw on 4xx/5xx: always check
response.okorresponse.statusexplicitly. - Axios interceptors are the right place to handle token refresh and global error normalization — keep them out of individual API calls.
- supertest for Node.js integration tests: test the full HTTP stack including middleware, routing, and response serialization without starting a real server.
- MSW intercepts at the network level: works with any fetch/axios call transparently and is the best mock for React component tests.
- Contract tests with Pact enable independent deployment — if the provider passes pact tests, it is safe to deploy without coordinating with consumers.
- k6 thresholds fail the test automatically: set
p(95)<500andrate<0.01to catch regressions in CI. - OpenAPI 3.1 is both documentation and contract: generate mocks, validate responses, and create SDKs from a single source of truth.
- schemathesis auto-generates test cases from your OpenAPI spec — it finds edge cases (empty strings, null values, boundary numbers) that hand-written tests miss.