Why API Testing Matters
APIs are the connective tissue of modern software. A typical web application makes dozens of API calls per page load. Mobile apps rely entirely on APIs for data. Microservices architectures can involve hundreds of internal API calls for a single user request. When an API breaks, everything downstream breaks with it.
API testing catches problems that UI tests miss. A form might submit successfully from the browser but accept malicious input that the API should reject. A mobile app might display data correctly while the API leaks sensitive fields it should not expose. Testing at the API layer gives you faster feedback, broader coverage, and more reliable results than testing through the UI alone.
You can start testing any API right now using the API Tester tool. It runs entirely in your browser, requires no installation, and supports all HTTP methods with custom headers and body content.
Types of API Tests
Unit Tests
Unit tests verify individual endpoints in isolation. External dependencies such as databases, third-party APIs, and message queues are mocked. These tests run in milliseconds and should cover the majority of your test suite.
// Jest + Supertest: unit testing an Express endpoint
const request = require('supertest');
const app = require('../app');
// Mock the database layer
jest.mock('../models/user');
const User = require('../models/user');
describe('GET /api/users/:id', () => {
it('returns a user when found', async () => {
User.findById.mockResolvedValue({
id: '123',
name: 'Jane Doe',
email: 'jane@example.com'
});
const response = await request(app)
.get('/api/users/123')
.expect(200);
expect(response.body.name).toBe('Jane Doe');
expect(response.body).not.toHaveProperty('password');
});
it('returns 404 when user not found', async () => {
User.findById.mockResolvedValue(null);
await request(app)
.get('/api/users/nonexistent')
.expect(404);
});
});
Integration Tests
Integration tests verify that multiple components work together correctly. They use real (or realistic) database connections and test the full request-response cycle including middleware, validation, serialization, and database queries.
// Integration test with real database
describe('POST /api/users', () => {
beforeEach(async () => {
await db.collection('users').deleteMany({});
});
it('creates a user and returns 201', async () => {
const response = await request(app)
.post('/api/users')
.send({
name: 'Jane Doe',
email: 'jane@example.com',
password: 'SecureP@ss123'
})
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.email).toBe('jane@example.com');
// Verify database state
const user = await db.collection('users')
.findOne({ email: 'jane@example.com' });
expect(user).not.toBeNull();
expect(user.password).not.toBe('SecureP@ss123'); // should be hashed
});
it('rejects duplicate email with 409', async () => {
// Create first user
await request(app).post('/api/users').send({
name: 'Jane', email: 'jane@example.com', password: 'Pass123!'
});
// Attempt duplicate
await request(app).post('/api/users').send({
name: 'Jane 2', email: 'jane@example.com', password: 'Pass456!'
}).expect(409);
});
});
End-to-End Tests
E2E tests verify complete user workflows across the entire system. They are the slowest and most brittle, so use them selectively for critical paths such as user registration, checkout, and payment flows.
Contract Tests
Contract tests verify that an API's response matches an agreed-upon schema. They are essential in microservices architectures where changes in one service can break consumers. You can validate response structures using the JSON Schema Validator.
Load Tests
Load tests measure API performance under traffic. They answer questions such as: How many requests per second can this endpoint handle? What happens to response times under 10x normal load? Where is the bottleneck?
// k6 load test script
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // Ramp up to 50 users
{ duration: '1m', target: 50 }, // Stay at 50 users
{ duration: '30s', target: 200 }, // Ramp to 200 users
{ duration: '1m', target: 200 }, // Stay at 200 users
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // Less than 1% failure rate
},
};
export default function () {
const res = http.get('https://api.example.com/products');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has products': (r) => JSON.parse(r.body).length > 0,
});
sleep(1);
}
HTTP Methods and Status Codes
Understanding HTTP semantics is fundamental to API testing. Each method has specific expectations, and status codes communicate outcomes precisely. For a quick reference during development, the HTTP Status Codes tool provides an interactive lookup for every code.
HTTP Methods
| Method | Purpose | Idempotent | Has Body |
|---|---|---|---|
GET | Retrieve a resource | Yes | No |
POST | Create a resource | No | Yes |
PUT | Replace a resource entirely | Yes | Yes |
PATCH | Partially update a resource | No* | Yes |
DELETE | Remove a resource | Yes | Rarely |
HEAD | GET without response body | Yes | No |
OPTIONS | Discover allowed methods | Yes | No |
*PATCH can be idempotent depending on implementation. A PATCH that sets status: "active" is idempotent. A PATCH that increments a counter is not.
Essential Status Codes
Success (2xx):
200 OK-- Successful GET, PUT, or PATCH201 Created-- Successful POST that created a resource (includeLocationheader)204 No Content-- Successful DELETE or action with no response body
Client Error (4xx):
400 Bad Request-- Malformed syntax, invalid JSON, missing required fields401 Unauthorized-- Missing or invalid authentication credentials403 Forbidden-- Authenticated but lacks permission for this resource404 Not Found-- Resource does not exist409 Conflict-- Resource already exists or state conflict422 Unprocessable Entity-- Valid syntax but semantic validation errors429 Too Many Requests-- Rate limit exceeded (includeRetry-Afterheader)
Server Error (5xx):
500 Internal Server Error-- Unexpected server failure502 Bad Gateway-- Upstream service failure503 Service Unavailable-- Server overloaded or in maintenance
If your API documentation says an endpoint can return 200, 400, 401, 404, and 500, write tests for all five. The error paths are where most bugs hide.
Request and Response Validation
What to Validate in Every Response
- Status code -- Is it the expected code for this scenario?
- Response body structure -- Does it match the schema? Are all required fields present?
- Data types -- Is the
ida string or number? Is thedatein ISO 8601 format? - Data values -- Does the created resource match the input? Are computed fields correct?
- Headers -- Content-Type, caching headers, rate limit headers, CORS headers
- Missing fields -- Are sensitive fields (passwords, tokens, internal IDs) excluded?
// Comprehensive response validation
it('validates the complete response structure', async () => {
const response = await request(app)
.get('/api/products/42')
.expect(200)
.expect('Content-Type', /json/);
const product = response.body;
// Structure
expect(product).toHaveProperty('id');
expect(product).toHaveProperty('name');
expect(product).toHaveProperty('price');
expect(product).toHaveProperty('createdAt');
// Types
expect(typeof product.id).toBe('string');
expect(typeof product.name).toBe('string');
expect(typeof product.price).toBe('number');
expect(Date.parse(product.createdAt)).not.toBeNaN();
// Values
expect(product.price).toBeGreaterThan(0);
expect(product.name.length).toBeGreaterThan(0);
// Excluded fields
expect(product).not.toHaveProperty('internalCost');
expect(product).not.toHaveProperty('supplierNotes');
});
Input Validation Testing
Test that your API rejects bad input with clear error messages. This is where security and reliability intersect.
describe('POST /api/products - input validation', () => {
const validProduct = {
name: 'Widget Pro',
price: 29.99,
category: 'tools'
};
it('rejects missing required fields', async () => {
const response = await request(app)
.post('/api/products')
.send({ name: 'Widget Pro' }) // missing price and category
.expect(422);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'price' })
);
});
it('rejects negative price', async () => {
await request(app)
.post('/api/products')
.send({ ...validProduct, price: -10 })
.expect(422);
});
it('rejects extremely long name', async () => {
await request(app)
.post('/api/products')
.send({ ...validProduct, name: 'x'.repeat(10001) })
.expect(422);
});
it('sanitizes HTML in text fields', async () => {
const response = await request(app)
.post('/api/products')
.send({ ...validProduct, name: '<script>alert("xss")</script>' })
.expect(201);
expect(response.body.name).not.toContain('<script>');
});
});
When debugging validation issues, it helps to format and inspect JSON responses. The JSON Formatter makes nested error objects easy to read, and the JSON Schema Validator can verify that responses match your API specification.
Authentication and Authorization Testing
Authentication (who are you?) and authorization (what can you do?) are the most critical areas to test. A single gap can expose user data or allow privilege escalation.
Authentication Test Cases
describe('Authentication', () => {
it('returns 401 with no token', async () => {
await request(app)
.get('/api/profile')
.expect(401);
});
it('returns 401 with expired token', async () => {
const expiredToken = generateToken({ userId: '123' }, '-1h');
await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
it('returns 401 with malformed token', async () => {
await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer not-a-real-token')
.expect(401);
});
it('returns 401 with token signed by wrong key', async () => {
const wrongKeyToken = jwt.sign(
{ userId: '123' },
'wrong-secret-key'
);
await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${wrongKeyToken}`)
.expect(401);
});
it('returns 200 with valid token', async () => {
const token = generateToken({ userId: '123' });
await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
});
});
When working with JWTs, the JWT Decoder lets you inspect token payloads, check expiration times, and verify claims without writing code. This is particularly useful during development and debugging.
Authorization Test Cases
describe('Authorization', () => {
it('prevents users from accessing other users data', async () => {
const userAToken = generateToken({ userId: 'user-a' });
await request(app)
.get('/api/users/user-b/settings')
.set('Authorization', `Bearer ${userAToken}`)
.expect(403);
});
it('prevents regular users from accessing admin endpoints', async () => {
const userToken = generateToken({ userId: '123', role: 'user' });
await request(app)
.get('/api/admin/users')
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
});
it('prevents privilege escalation via request body', async () => {
const userToken = generateToken({ userId: '123', role: 'user' });
// Attempt to set own role to admin
await request(app)
.patch('/api/users/123')
.set('Authorization', `Bearer ${userToken}`)
.send({ role: 'admin' })
.expect(403); // or 422, depending on implementation
});
});
Insecure Direct Object Reference (IDOR) means a user can access or modify another user's resources by changing an ID in the URL or request body. Test every endpoint that takes a user-specific ID: /api/users/{id}/orders, /api/invoices/{id}, etc. Ensure the authenticated user owns or has access to the requested resource.
Test Automation Patterns
Test Data Management
Reliable tests need predictable data. Use factories and fixtures instead of hardcoded values, and clean up after each test run.
// Test data factory
function createUserData(overrides = {}) {
return {
name: `Test User ${Date.now()}`,
email: `test-${Date.now()}@example.com`,
password: 'SecureP@ss123!',
...overrides
};
}
// Setup and teardown
beforeEach(async () => {
await db.collection('users').deleteMany({
email: { $regex: /^test-/ }
});
});
// Usage
it('creates a user', async () => {
const userData = createUserData({ name: 'Specific Name' });
const res = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(res.body.name).toBe('Specific Name');
});
Testing Pagination
describe('Pagination', () => {
beforeAll(async () => {
// Seed 50 products
const products = Array.from({ length: 50 }, (_, i) => ({
name: `Product ${i + 1}`,
price: (i + 1) * 10
}));
await db.collection('products').insertMany(products);
});
it('returns first page with default limit', async () => {
const res = await request(app)
.get('/api/products')
.expect(200);
expect(res.body.data.length).toBe(20); // default page size
expect(res.body.total).toBe(50);
expect(res.body.page).toBe(1);
expect(res.body.hasNextPage).toBe(true);
});
it('returns second page', async () => {
const res = await request(app)
.get('/api/products?page=2&limit=20')
.expect(200);
expect(res.body.data.length).toBe(20);
expect(res.body.page).toBe(2);
});
it('returns empty array for page beyond data', async () => {
const res = await request(app)
.get('/api/products?page=100')
.expect(200);
expect(res.body.data.length).toBe(0);
expect(res.body.hasNextPage).toBe(false);
});
});
Testing with cURL
cURL is the universal API testing tool. Every developer should be comfortable with basic cURL commands. You can convert cURL commands to code in multiple languages using the cURL to Code converter.
# GET request
curl -s https://api.example.com/users/123 | jq .
# POST with JSON body
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"name": "Jane", "email": "jane@example.com"}'
# PUT request
curl -X PUT https://api.example.com/users/123 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"name": "Jane Updated", "email": "jane@example.com"}'
# DELETE request
curl -X DELETE https://api.example.com/users/123 \
-H "Authorization: Bearer YOUR_TOKEN" \
-v # verbose output shows headers and status code
# Test rate limiting (send 100 requests)
for i in $(seq 1 100); do
curl -s -o /dev/null -w "%{http_code}\n" \
https://api.example.com/products
done | sort | uniq -c
Common Mistakes and How to Avoid Them
1. Only Testing Happy Paths
The biggest mistake in API testing is only verifying that things work when everything goes right. The real bugs live in the error handling code that rarely gets exercised.
What to test instead:
- Missing required fields, null values, empty strings
- Invalid data types (string where number expected)
- Boundary values (0, negative numbers, maximum lengths)
- Special characters and Unicode in text fields
- Extremely large payloads
- Concurrent requests that could cause race conditions
2. Not Validating Response Schemas
Checking the status code but ignoring the response body structure is a recipe for undetected breaking changes. A response that returns 200 but is missing the pagination object will break every consumer.
// Bad: only checks status
it('returns products', async () => {
await request(app).get('/api/products').expect(200);
});
// Good: validates structure
it('returns products with correct schema', async () => {
const res = await request(app).get('/api/products').expect(200);
expect(res.body).toHaveProperty('data');
expect(res.body).toHaveProperty('total');
expect(res.body).toHaveProperty('page');
expect(Array.isArray(res.body.data)).toBe(true);
if (res.body.data.length > 0) {
expect(res.body.data[0]).toHaveProperty('id');
expect(res.body.data[0]).toHaveProperty('name');
expect(res.body.data[0]).toHaveProperty('price');
}
});
3. Hardcoding Test Data
Tests that depend on specific database IDs or records break when the database is reset or shared between test suites. Always create the data your test needs within the test itself.
4. Ignoring Performance
An endpoint that works correctly but takes 5 seconds to respond is broken for users. Include response time assertions in your tests.
it('responds within acceptable time', async () => {
const start = Date.now();
await request(app).get('/api/products').expect(200);
const duration = Date.now() - start;
expect(duration).toBeLessThan(500); // 500ms threshold
});
5. Skipping Authentication Edge Cases
Testing with a valid token is necessary but insufficient. Test with expired tokens, tokens from deleted users, tokens with modified payloads, and tokens signed with wrong keys.
6. Not Testing API Versioning
If your API supports versioning (via URL path, headers, or query parameters), test that v1 consumers are not broken by v2 changes.
Always clean up test data between runs. Stale data from previous test runs is one of the most common causes of flaky tests. Use beforeEach to reset state, not afterEach, because afterEach might not run if a test crashes.
Tools and Frameworks Comparison
Manual / Exploratory Testing
| Tool | Best For | Cost |
|---|---|---|
| QTool API Tester | Quick tests, no setup, browser-based | Free |
| Postman | Team collections, environments, scripts | Free tier / Paid |
| Insomnia | REST and GraphQL, clean interface | Free tier / Paid |
| cURL | Scriptable, available everywhere | Free |
| HTTPie | Developer-friendly CLI | Free |
Automated Testing Frameworks
| Framework | Language | Best For |
|---|---|---|
| Jest + Supertest | JavaScript | Node.js/Express APIs |
| Vitest + Supertest | TypeScript | Modern JS/TS APIs with fast test runner |
| pytest + requests | Python | Flask/Django/FastAPI |
| REST Assured | Java | Spring Boot APIs |
| Playwright API | Multi-language | Combined UI + API testing |
| Dredd | Any (OpenAPI) | Testing against API spec |
Load Testing
| Tool | Approach | Best For |
|---|---|---|
| k6 | JavaScript scripts | Developer-friendly, CI/CD integration |
| Artillery | YAML config | Quick setup, scenario-based |
| Apache JMeter | GUI + XML | Enterprise, complex scenarios |
| Locust | Python scripts | Python teams, distributed testing |
For quick, one-off API tests during development, a browser-based tool is fastest. For tests that run in CI/CD, a code-based framework integrated into your project is essential. For testing webhooks during development, the Webhook Tester gives you a unique URL that captures incoming requests for inspection.
Related Developer Tools
These free browser-based tools support your API testing workflow. No installation, no signup.
Frequently Asked Questions
The main types of API testing are: Unit testing (testing individual endpoints in isolation with mocked dependencies), Integration testing (testing how multiple endpoints and services work together with real databases), End-to-end testing (testing complete user workflows across the entire system), Contract testing (verifying that API responses match the agreed-upon schema), Load testing (measuring performance under expected and peak traffic), and Security testing (checking authentication, authorization, input validation, and data exposure). Most teams focus on unit and integration tests for the majority of their coverage, with selective E2E tests for critical paths.
The most commonly used HTTP status codes are: 200 OK (successful GET, PUT, PATCH), 201 Created (successful POST that created a resource), 204 No Content (successful DELETE), 400 Bad Request (invalid input or malformed request), 401 Unauthorized (missing or invalid authentication), 403 Forbidden (authenticated but not authorized), 404 Not Found (resource does not exist), 409 Conflict (duplicate resource or state conflict), 422 Unprocessable Entity (validation errors), 429 Too Many Requests (rate limited), and 500 Internal Server Error (unexpected server failure). Use the most specific status code that applies rather than generic 200 or 400 responses.
Test authentication by verifying: requests without credentials return 401, expired tokens return 401, invalid tokens return 401, and valid credentials return 200 with the expected response. Test authorization by verifying: users cannot access resources they do not own (should return 403), role-based access works correctly (admin vs regular user), and privilege escalation is not possible by modifying request parameters. Always test the negative cases: can a regular user access admin endpoints? Can user A modify user B's data by changing the ID in the URL? Test token expiration, refresh token flows, and concurrent session limits.
API unit tests test individual endpoints in isolation. Dependencies like databases, external services, and message queues are mocked or stubbed. They are fast (milliseconds per test), deterministic, and focused on business logic. API integration tests test multiple components working together with real (or realistic) dependencies. They use actual database connections, make real HTTP requests, and verify the full request-response cycle. They are slower but catch issues that unit tests miss, such as database query errors, serialization problems, and middleware bugs. A good testing strategy uses many unit tests for speed and confidence, plus fewer integration tests for critical paths.
Common API testing mistakes include: only testing happy paths (ignoring error cases, edge cases, and invalid input), not validating response schemas (checking status codes but not response body structure), hardcoding test data that breaks when the database changes, not testing with realistic data volumes, ignoring performance under concurrent requests, testing against production APIs instead of staging environments, not cleaning up test data between runs, skipping authentication and authorization testing, and not testing API versioning and backward compatibility. The biggest mistake is treating API tests as an afterthought instead of building them alongside the API.
For manual and exploratory testing, use browser-based tools like QTool API Tester (free, no signup) or Postman (feature-rich, requires account). For automated testing in code, popular options include: Jest with Supertest (JavaScript/Node.js), pytest with requests (Python), REST Assured (Java), and Playwright API testing (cross-language). For load testing, use k6 (scriptable, developer-friendly), Artillery (YAML-based), or Apache JMeter (GUI, enterprise). For contract testing, use Pact. For API monitoring in production, consider tools like Checkly or Uptime Robot. Most teams use a combination: a browser tool for exploration, a code-based framework for automated tests, and a monitoring tool for production.