API testing is the most underrated skill in QA.
Most teams focus on UI testing (Selenium, Playwright). But 60% of bugs live in the API layer—authentication failures, data corruption, race conditions—invisible in UI tests.
I've tested APIs across 12 projects: fintech payment systems, healthcare data pipelines, AI agent backends. This guide covers everything I've learned.
Why API Testing Matters (And Why You're Probably Neglecting It)
The Numbers:
- UI tests: 1 endpoint, multiple steps, 30 seconds per test
- API tests: 100 endpoints, isolated, 1 second per test
- Coverage: Same feature coverage in 1/30th the time
Real Example (AI Sales Assistant):
- UI test: Login → Browse products → Add to cart → Checkout (4 minutes)
- API test: POST /checkout with payload validation (10 seconds)
- Same coverage. 24x faster.
API Testing Fundamentals
What Are You Actually Testing?
APIs are contracts between client (frontend) and server (backend).
Client sends: GET /api/users/1
Server responds: {"id": 1, "name": "John", "email": "john@example.com"}
API Test checks:
✅ Status code = 200
✅ Response has required fields
✅ Data types are correct (id is number, not string)
✅ No extra fields (security)
The Three Layers of API Testing
Layer 1: Contract Testing
- Does the API respond at all?
- Is the HTTP status correct? (200, 201, 400, 401, 500)
- Does the response have the right structure?
test('GET /users/:id returns correct structure', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.status()).toBe(200);
const user = await response.json();
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('email');
expect(typeof user.id).toBe('number');
});
Layer 2: Business Logic Testing
- Does the API calculate/return the right values?
- Are edge cases handled? (0, negative, null, empty)
- Do dependent APIs work together?
test('Lead qualification API returns correct score', async ({ request }) => {
const response = await request.post('/api/qualify-lead', {
data: {
name: 'Acme Corp',
employees: 500,
revenue: '$10M',
industry: 'fintech'
}
});
const qualification = await response.json();
expect(qualification.score).toBeGreaterThan(0.8); // Should be hot lead
expect(qualification.nextStep).toBe('demo');
});
Layer 3: Integration Testing
- Does API A work with Database B and Service C?
- Are transactions atomic? (All-or-nothing)
- Are race conditions possible?
test('Create user → Assign permissions → Verify in database', async ({ request }) => {
// Step 1: Create user
const createResponse = await request.post('/api/users', {
data: { name: 'John', email: 'john@example.com' }
});
const userId = (await createResponse.json()).id;
// Step 2: Assign permissions
await request.post(`/api/users/${userId}/permissions`, {
data: { role: 'admin' }
});
// Step 3: Verify in database (via separate API call)
const userResponse = await request.get(`/api/users/${userId}`);
const user = await userResponse.json();
expect(user.role).toBe('admin');
});
Testing REST APIs
HTTP Methods & What to Test
| Method | Purpose | Test What |
|---|---|---|
| GET | Read data | Status 200, correct data, no side effects |
| POST | Create data | Status 201, ID returned, data persisted |
| PUT | Replace data | Status 200, all fields updated, old data gone |
| PATCH | Partial update | Status 200, only sent fields updated, others unchanged |
| DELETE | Remove data | Status 204 or 200, data actually deleted |
Testing CRUD Operations (Create, Read, Update, Delete)
test.describe('User CRUD', () => {
let userId: number;
// CREATE
test('POST /users creates new user', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'John Doe', email: 'john@example.com' }
});
expect(response.status()).toBe(201);
const user = await response.json();
userId = user.id;
expect(userId).toBeDefined();
});
// READ
test('GET /users/:id retrieves user', async ({ request }) => {
const response = await request.get(`/api/users/${userId}`);
expect(response.status()).toBe(200);
const user = await response.json();
expect(user.name).toBe('John Doe');
});
// UPDATE
test('PATCH /users/:id updates user', async ({ request }) => {
const response = await request.patch(`/api/users/${userId}`, {
data: { name: 'Jane Doe' }
});
expect(response.status()).toBe(200);
const user = await response.json();
expect(user.name).toBe('Jane Doe');
expect(user.email).toBe('john@example.com'); // Unchanged
});
// DELETE
test('DELETE /users/:id removes user', async ({ request }) => {
const response = await request.delete(`/api/users/${userId}`);
expect(response.status()).toBe(204); // No content
// Verify deletion
const getResponse = await request.get(`/api/users/${userId}`);
expect(getResponse.status()).toBe(404); // Not found
});
});
Authentication & Authorization Testing
Testing Protected Endpoints
test.describe('Authentication', () => {
let authToken: string;
// Get auth token
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: 'user@example.com',
password: 'password123'
}
});
const data = await response.json();
authToken = data.token;
});
// Test protected endpoint
test('GET /api/profile requires authentication', async ({ request }) => {
// Without token
const noAuthResponse = await request.get('/api/profile');
expect(noAuthResponse.status()).toBe(401); // Unauthorized
// With token
const withAuthResponse = await request.get('/api/profile', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
expect(withAuthResponse.status()).toBe(200);
});
// Test invalid token
test('Invalid token should fail', async ({ request }) => {
const response = await request.get('/api/profile', {
headers: { 'Authorization': 'Bearer invalid-token' }
});
expect(response.status()).toBe(401);
});
});
Testing GraphQL APIs
GraphQL Query Testing
test('GraphQL query for user data', async ({ request }) => {
const response = await request.post('/graphql', {
data: {
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
}
}
}
`,
variables: { id: '1' }
}
});
expect(response.status()).toBe(200);
const result = await response.json();
expect(result.data.user.name).toBeDefined();
expect(result.data.user.posts).toBeInstanceOf(Array);
});
test('GraphQL mutation to create post', async ({ request }) => {
const response = await request.post('/graphql', {
data: {
query: `
mutation CreatePost($title: String!, $content: String!) {
createPost(title: $title, content: $content) {
id
title
createdAt
}
}
`,
variables: {
title: 'My First Post',
content: 'Post content here'
}
}
});
expect(response.status()).toBe(200);
const result = await response.json();
expect(result.data.createPost.id).toBeDefined();
expect(result.data.createPost.title).toBe('My First Post');
});
Error Handling & Status Codes
Testing Error Responses
test.describe('Error Handling', () => {
// 400: Bad Request
test('Missing required field returns 400', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John'
// Missing: email
}
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.message).toContain('email is required');
});
// 401: Unauthorized
test('Protected endpoint without auth returns 401', async ({ request }) => {
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
// 403: Forbidden
test('Non-admin cannot access admin endpoint', async ({ request }) => {
const response = await request.get('/api/admin/users', {
headers: { 'Authorization': 'Bearer user-token' } // Regular user
});
expect(response.status()).toBe(403); // Forbidden
});
// 404: Not Found
test('Non-existent user returns 404', async ({ request }) => {
const response = await request.get('/api/users/999999');
expect(response.status()).toBe(404);
});
// 409: Conflict
test('Duplicate email returns 409', async ({ request }) => {
// Create user
await request.post('/api/users', {
data: { name: 'John', email: 'john@example.com' }
});
// Try to create with same email
const response = await request.post('/api/users', {
data: { name: 'Jane', email: 'john@example.com' }
});
expect(response.status()).toBe(409); // Conflict
});
// 500: Internal Server Error
test('Server error returns 500', async ({ request }) => {
const response = await request.get('/api/broken-endpoint');
expect(response.status()).toBe(500);
expect(response.status()).toBeGreaterThanOrEqual(500);
});
});
API Testing with Postman
Postman Workflow
- Create Collection: Group related requests (Users, Products, Orders)
- Set Variables: Base URL, auth tokens, test data
- Write Tests: Assertions in "Tests" tab
- Run Collection: Execute all requests sequentially
Example Postman Test
// In Postman's "Tests" tab
pm.test(\"Status code is 200\", function() {
pm.response.to.have.status(200);
});
pm.test(\"Response has user data\", function() {
var jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('id');
pm.expect(jsonData.email).to.equal('user@example.com');
});
pm.test(\"Response time is acceptable\", function() {
pm.expect(pm.response.responseTime).to.be.below(1000); // < 1 second
});
// Store token for next request
pm.environment.set(\"auth_token\", pm.response.json().token);
Data Validation & Edge Cases
Testing Edge Cases
test.describe('Edge Cases', () => {
// Null/empty values
test('API handles empty string gracefully', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: '', email: 'test@example.com' }
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.message).toContain('name cannot be empty');
});
// Very large values
test('API rejects excessively large input', async ({ request }) => {
const largeString = 'a'.repeat(10000);
const response = await request.post('/api/comments', {
data: { text: largeString }
});
expect(response.status()).toBe(400);
});
// Special characters
test('API sanitizes special characters', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: '',
email: 'test@example.com'
}
});
expect(response.status()).toBe(201);
const user = await response.json();
expect(user.name).not.toContain('