REST API Testing Best Practices Every QA Engineer Must Know
REST API testing looks simple on the surface: send a request, check the response. In reality, most teams get it wrong.
I've audited 40+ API test suites across fintech, healthcare, and SaaS. The problems are always the same:
- Tests that pass locally but fail in CI (environment differences)
- Fragile tests that break when a developer renames a field
- Zero coverage of edge cases (null, empty arrays, 10MB payloads)
- No test isolation (test A leaves data that breaks test B)
- Assertions on implementation details instead of contracts
This guide covers the fundamentals that separate good API test suites from mediocre ones.
Table of Contents
- Status Code Mastery
- Headers and Content Negotiation
- Authentication Testing
- Error Handling and Edge Cases
- Test Organization and Isolation
- Anti-Patterns to Avoid
- Real Project Example
- FAQ
Status Code Mastery
Status codes are the API's way of telling you what happened. Most teams test for 200 and call it done.
Common Status Codes
The fundamentals: 2xx = success, 3xx = redirect, 4xx = client error, 5xx = server error.
Test all status codes for critical endpoints:
// Successful request (201 Created)
test('Create user: 201', async ({ request }) => {
const res = await request.post('https://api.example.com/users', {
data: { email: 'user@example.com', name: 'John' }
});
expect(res.status()).toBe(201);
});
// Invalid input (400 Bad Request)
test('Create user: 400 Missing email', async ({ request }) => {
const res = await request.post('https://api.example.com/users', {
data: { name: 'John' }
});
expect(res.status()).toBe(400);
});
// Not authenticated (401 Unauthorized)
test('Get protected: 401 No token', async ({ request }) => {
const res = await request.get('https://api.example.com/protected');
expect(res.status()).toBe(401);
});
// Not found (404)
test('Get user: 404', async ({ request }) => {
const res = await request.get('https://api.example.com/users/99999');
expect(res.status()).toBe(404);
});
Headers and Content Negotiation
Headers control request/response format and authentication.
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer TOKEN',
'User-Agent': 'MyApp/1.0',
};
Authentication Testing
Test all authentication scenarios:
test.describe('Authentication', () => {
// No token
test('401 when no token provided', async ({ request }) => {
const res = await request.get('https://api.example.com/protected');
expect(res.status()).toBe(401);
});
// Invalid token
test('401 when token is invalid', async ({ request }) => {
const res = await request.get('https://api.example.com/protected', {
headers: { 'Authorization': 'Bearer invalid' }
});
expect(res.status()).toBe(401);
});
// Valid token
test('200 with valid token', async ({ request }) => {
const res = await request.get('https://api.example.com/protected', {
headers: { 'Authorization': 'Bearer valid-token' }
});
expect(res.status()).toBe(200);
});
});
Error Handling and Edge Cases
Test edge cases thoroughly:
test.describe('Edge Cases', () => {
// Null values
test('400 when required field is null', async ({ request }) => {
const res = await request.post('https://api.example.com/users', {
data: { email: null, name: 'John' }
});
expect(res.status()).toBe(400);
});
// Empty string
test('400 when required field is empty', async ({ request }) => {
const res = await request.post('https://api.example.com/users', {
data: { email: '', name: 'John' }
});
expect(res.status()).toBe(400);
});
// Boundary: max length
test('400 when input exceeds max length', async ({ request }) => {
const res = await request.post('https://api.example.com/users', {
data: { email: 'test@example.com', name: 'A'.repeat(256) }
});
expect(res.status()).toBe(400);
});
// Special characters
test('201 with special characters', async ({ request }) => {
const res = await request.post('https://api.example.com/users', {
data: { email: 'test@example.com', name: 'José García' }
});
expect(res.status()).toBe(201);
const user = await res.json();
expect(user.name).toBe('José García');
});
});
Test Organization and Isolation
Good test organization prevents cascading failures.
Setup and Teardown
test.beforeEach(async ({ request }) => {
// Create test data before each test
const response = await request.post('https://api.example.com/users', {
data: { email: 'test@example.com', name: 'Test User' }
});
testUserId = (await response.json()).id;
});
test.afterEach(async ({ request }) => {
// Clean up after each test
await request.delete(`https://api.example.com/users/${testUserId}`);
});
test('GET user returns correct data', async ({ request }) => {
const res = await request.get(`https://api.example.com/users/${testUserId}`);
expect(res.status()).toBe(200);
const user = await res.json();
expect(user.email).toBe('test@example.com');
});
Independent Tests
Good: Each test is independent
test('Create and retrieve user', async ({ request }) => {
const createRes = await request.post('https://api.example.com/users', {
data: { email: 'new@example.com', name: 'New User' }
});
const userId = (await createRes.json()).id;
const getRes = await request.get(`https://api.example.com/users/${userId}`);
expect(getRes.status()).toBe(200);
});
Anti-Patterns to Avoid
Hard-Coded IDs
Bad: ID might not exist
test('Get user', async ({ request }) => {
const res = await request.get('https://api.example.com/users/12345');
expect(res.status()).toBe(200);
});
Good: Create resource first
test('Create and get user', async ({ request }) => {
const createRes = await request.post('https://api.example.com/users', {
data: { email: 'test@example.com', name: 'Test' }
});
const userId = (await createRes.json()).id;
const getRes = await request.get(`https://api.example.com/users/${userId}`);
expect(getRes.status()).toBe(200);
});
Testing Implementation Details
Bad: Brittle
test('User object has exactly 8 properties', async ({ request }) => {
const res = await request.get('https://api.example.com/users/1');
const user = await res.json();
expect(Object.keys(user).length).toBe(8);
});
Good: Robust
test('User has required fields', async ({ request }) => {
const res = await request.get('https://api.example.com/users/1');
const user = await res.json();
expect(user.id).toBeDefined();
expect(user.email).toBeDefined();
});
Real Project Example
On my Wells Fargo automation project, I tested a payment API with 150 tests covering:
- All status codes (200, 201, 400, 401, 403, 404, 409, 500)
- Authentication scenarios (no token, invalid, expired, valid)
- Edge cases (null, empty, boundary values, special characters)
- Error handling
Result: 3 validation bugs caught, 2 auth bugs fixed, zero production issues.
FAQ
Q: How many tests per endpoint?
A: Minimum 1-2 per status code. For critical endpoints: 10-20 total.
Q: Should API tests run in CI/CD?
A: Yes, every push to main/develop branches.
Q: Test contract or implementation?
A: Contract only. Test status codes, required fields, data types not internal logic.
Q: How to handle rate limits in tests?
A: Add delays between requests or use test data that doesn't trigger limits.
Tayyab Akmal
AI & QA Automation Engineer
I've caught critical bugs in fintech, e-commerce, and SaaS platforms — then built the automation that prevents them from shipping again. 6+ years scaling test automation and AI-driven QA.