API Testing Best Practices: 10 Rules That Prevent Production Outages
While UI testing gets most of the attention, API testing is often more efficient and catches bugs earlier in the development cycle. A robust API test suite provides fast feedback, validates business logic directly, and serves as living documentation. This comprehensive guide covers everything from basic REST testing to advanced GraphQL and contract testing strategies.
Why API Testing Matters
graph LR
A[Unit Tests<br/>10ms] --> B[API Tests<br/>100ms]
B --> C[Integration Tests<br/>1s]
C --> D[E2E Tests<br/>10s]
style A fill:#6bcf7f
style B fill:#95e1d3
style C fill:#ffd93d
style D fill:#ff9a76
The Testing Pyramid for Modern Apps:
| Test Level | Speed | Coverage | Maintenance | Typical Count |
|---|---|---|---|---|
| Unit Tests | Fastest | Code logic | Low | 1000+ |
| API Tests | Fast | Business logic | Low | 200-500 |
| Integration Tests | Medium | System integration | Medium | 50-100 |
| E2E Tests | Slow | User workflows | High | 20-50 |
Benefits of API Testing:
- Faster execution than UI tests
- More stable (no UI flakiness)
- Tests business logic directly
- Early bug detection
- Serves as API documentation
- Easier to maintain
Getting Started with Playwright API Testing
Playwright isn't just for browser testing—it has powerful built-in API testing capabilities.
Basic REST API Testing
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User API', () => {
let apiContext;
test.beforeAll(async ({ playwright }) => {
// Create API request context
apiContext = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
});
test.afterAll(async () => {
await apiContext.dispose();
});
test('GET /users returns user list', async () => {
const response = await apiContext.get('/users');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const users = await response.json();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThan(0);
// Validate user structure
expect(users[0]).toHaveProperty('id');
expect(users[0]).toHaveProperty('email');
expect(users[0]).toHaveProperty('name');
});
test('POST /users creates a new user', async () => {
const newUser = {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: 'SecurePass123!',
};
const response = await apiContext.post('/users', {
data: newUser,
});
expect(response.status()).toBe(201);
const createdUser = awaitresponse.json();
expect(createdUser.id).toBeDefined();
expect(createdUser.email).toBe(newUser.email);
expect(createdUser.name).toBe(newUser.name);
expect(createdUser.password).toBeUndefined(); // Should not return password
});
test('PUT /users/:id updates user', async () => {
// First, create a user
const createResponse = await apiContext.post('/users', {
data: {
email: `update-${Date.now()}@example.com`,
name: 'Original Name',
password: 'Pass123!',
},
});
const user = await createResponse.json();
// Update the user
const updateResponse = await apiContext.put(`/users/${user.id}`, {
data: {
name: 'Updated Name',
},
});
expect(updateResponse.status()).toBe(200);
const updatedUser = await updateResponse.json();
expect(updatedUser.name).toBe('Updated Name');
expect(updatedUser.email).toBe(user.email); // Unchanged
});
test('DELETE /users/:id removes user', async () => {
// Create a user to delete
const createResponse = await apiContext.post('/users', {
data: {
email: `delete-${Date.now()}@example.com`,
name: 'To Delete',
password: 'Pass123!',
},
});
const user = await createResponse.json();
// Delete the user
const deleteResponse = await apiContext.delete(`/users/${user.id}`);
expect(deleteResponse.status()).toBe(204);
// Verify deletion
const getResponse = await apiContext.get(`/users/${user.id}`);
expect(getResponse.status()).toBe(404);
});
});
Authentication and Authorization Testing
// tests/api/auth.spec.ts
test.describe('Authentication', () => {
let apiContext;
let authToken: string;
test.beforeAll(async ({ playwright }) => {
apiContext = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL!,
});
// Obtain auth token
const loginResponse = await apiContext.post('/auth/login', {
data: {
email: 'test@example.com',
password: 'TestPassword123!',
},
});
const loginData = await loginResponse.json();
authToken = loginData.token;
});
test('protected endpoint requires authentication', async () => {
// Without auth token
const response = await apiContext.get('/api/protected');
expect(response.status()).toBe(401);
const error = await response.json();
expect(error.message).toContain('authentication');
});
test('protected endpoint accessible with valid token', async () => {
const response = await apiContext.get('/api/protected', {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(response.ok()).toBeTruthy();
});
test('expired token returns 401', async () => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...expired';
const response = await apiContext.get('/api/protected', {
headers: {
Authorization: `Bearer ${expiredToken}`,
},
});
expect(response.status()).toBe(401);
});
test('admin endpoint requires admin role', async () => {
// Regular user token
const response = await apiContext.get('/api/admin/users', {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(response.status()).toBe(403); // Forbidden
});
test('admin endpoint accessible to admin', async () => {
// Get admin token
const adminLogin = await apiContext.post('/auth/login', {
data: {
email: 'admin@example.com',
password: 'AdminPass123!',
},
});
const adminData = await adminLogin.json();
const response = await apiContext.get('/api/admin/users', {
headers: {
Authorization: `Bearer ${adminData.token}`,
},
});
expect(response.ok()).toBeTruthy();
});
});
Error Handling and Edge Cases
// tests/api/error-handling.spec.ts
test.describe('Error Handling', () => {
test('invalid request body returns 400', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
email: 'not-an-email', // Invalid email
name: '', // Empty name
},
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.errors).toBeDefined();
expect(error.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
field: 'email',
message: expect.stringContaining('valid email'),
}),
expect.objectContaining({
field: 'name',
message: expect.stringContaining('required'),
}),
]),
);
});
test('resource not found returns 404', async ({ request }) => {
const response = await request.get('/api/users/non-existent-id');
expect(response.status()).toBe(404);
const error = await response.json();
expect(error.message).toContain('not found');
});
test('duplicate resource returns 409', async ({ request }) => {
const email = `duplicate-${Date.now()}@example.com`;
// Create user
await request.post('/api/users', {
data: { email, name: 'User 1', password: 'Pass123!' },
});
// Attempt to create duplicate
const response = await request.post('/api/users', {
data: { email, name: 'User 2', password: 'Pass123!' },
});
expect(response.status()).toBe(409);
const error = await response.json();
expect(error.message).toContain('already exists');
});
test('malformed JSON returns 400', async ({ request }) => {
const response = await request.post('/api/users', {
data: 'not valid json',
});
expect(response.status()).toBe(400);
});
test('rate limiting returns 429', async ({ request }) => {
// Make many requests quickly
const requests = Array.from({ length: 100 }, () => request.get('/api/public/status'));
const responses = await Promise.all(requests);
// At least one should be rate limited
const rateLimited = responses.filter((r) => r.status() === 429);
expect(rateLimited.length).toBeGreaterThan(0);
// Check rate limit headers
const limitedResponse = rateLimited[0];
expect(limitedResponse.headers()['retry-after']).toBeDefined();
});
});
GraphQL API Testing
GraphQL requires a slightly different approach than REST.
Basic GraphQL Queries
// tests/api/graphql.spec.ts
test.describe('GraphQL API', () => {
async function graphqlRequest(query: string, variables?: Record<string, any>) {
const response = await apiContext.post('/graphql', {
data: {
query,
variables,
},
});
const json = await response.json();
// Check for GraphQL errors
if (json.errors) {
throw new Error(`GraphQL Error: ${JSON.stringify(json.errors)}`);
}
return json.data;
}
test('query users with fields', async () => {
const data = await graphqlRequest(`
query GetUsers {
users {
id
email
name
createdAt
}
}
`);
expect(data.users).toBeDefined();
expect(Array.isArray(data.users)).toBe(true);
expect(data.users.length).toBeGreaterThan(0);
const user = data.users[0];
expect(user.id).toBeDefined();
expect(user.email).toBeDefined();
expect(user.name).toBeDefined();
expect(user.createdAt).toBeDefined();
});
test('query user by ID', async () => {
const data = await graphqlRequest(
`
query GetUser($id: ID!) {
user(id: $id) {
id
email
name
}
}
`,
{
id: 'user-123',
},
);
expect(data.user).toBeDefined();
expect(data.user.id).toBe('user-123');
});
test('mutation creates user', async () => {
const data = await graphqlRequest(
`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
name
}
}
`,
{
input: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: 'SecurePass123!',
},
},
);
expect(data.createUser.id).toBeDefined();
expect(data.createUser.email).toBeDefined();
});
test('nested queries work correctly', async () => {
const data = await graphqlRequest(
`
query GetUserWithProjects($userId: ID!) {
user(id: $userId) {
id
name
projects {
id
name
scans {
id
status
}
}
}
}
`,
{
userId: 'user-123',
},
);
expect(data.user.projects).toBeDefined();
expect(Array.isArray(data.user.projects)).toBe(true);
if (data.user.projects.length > 0) {
expect(data.user.projects[0].scans).toBeDefined();
}
});
test('GraphQL validation errors', async () => {
try {
await graphqlRequest(
`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
}
}
`,
{
input: {
email: 'invalid-email',
name: '',
},
},
);
fail('Should have thrown validation error');
} catch (error) {
expect(error.message).toContain('GraphQL Error');
}
});
});
Schema Validation and Contract Testing
JSON Schema Validation
// tests/api/schema-validation.spec.ts
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv();
addFormats(ajv);
// Define schemas
const userSchema = {
type: 'object',
required: ['id', 'email', 'name', 'createdAt'],
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1 },
createdAt: { type: 'string', format: 'date-time' },
},
additionalProperties: true,
};
const userListSchema = {
type: 'array',
items: userSchema,
};
test.describe('Schema Validation', () => {
test('GET /users returns valid schema', async ({ request }) => {
const response = await request.get('/api/users');
const users = await response.json();
const validate = ajv.compile(userListSchema);
const valid = validate(users);
if (!valid) {
console.error('Validation errors:', validate.errors);
}
expect(valid).toBe(true);
});
test('POST /users returns valid user schema', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: 'Pass123!',
},
});
const user = await response.json();
const validate = ajv.compile(userSchema);
const valid = validate(user);
expect(valid).toBe(true);
});
});
OpenAPI Contract Testing
// tests/api/openapi-contract.spec.ts
import fs from 'fs';
import yaml from 'js-yaml';
import { OpenAPIValidator } from 'express-openapi-validator';
// Load OpenAPI spec
const openapiSpec = yaml.load(fs.readFileSync('./openapi.yaml', 'utf8')) as any;
test.describe('OpenAPI Contract', () => {
test('API responses match OpenAPI spec', async ({ request }) => {
// Test each endpoint defined in OpenAPI spec
const paths = Object.keys(openapiSpec.paths);
for (const path of paths) {
const methods = Object.keys(openapiSpec.paths[path]);
for (const method of methods) {
if (method === 'get') {
const response = await request.get(path);
// Validate response against spec
const expectedStatus = Object.keys(openapiSpec.paths[path][method].responses)[0];
expect(response.status()).toBe(parseInt(expectedStatus));
const data = await response.json();
const schema = openapiSpec.paths[path][method].responses[expectedStatus].content['application/json'].schema;
// Validate schema
const validate = ajv.compile(schema);
expect(validate(data)).toBe(true);
}
}
}
});
});
Performance Testing
// tests/api/performance.spec.ts
test.describe('API Performance', () => {
test('GET /users responds within 200ms', async ({ request }) => {
const start = Date.now();
const response = await request.get('/api/users');
const duration = Date.now() - start;
expect(response.ok()).toBeTruthy();
expect(duration).toBeLessThan(200);
});
test('handles concurrent requests', async ({ request }) => {
const requests = Array.from({ length: 50 }, () => request.get('/api/users'));
const start = Date.now();
const responses = await Promise.all(requests);
const duration = Date.now() - start;
// All should succeed
expect(responses.every((r) => r.ok())).toBe(true);
// Should handle 50 requests in reasonable time
expect(duration).toBeLessThan(5000); // 5 seconds
});
test('pagination performs efficiently', async ({ request }) => {
const pageSize = 100;
const pages = 10;
const timings: number[] = [];
for (let page = 1; page <= pages; page++) {
const start = Date.now();
const response = await request.get(`/api/users?page=${page}&limit=${pageSize}`);
timings.push(Date.now() - start);
expect(response.ok()).toBeTruthy();
}
// Later pages shouldn't be significantly slower
const avgFirst3 = timings.slice(0, 3).reduce((a, b) => a + b) / 3;
const avgLast3 = timings.slice(-3).reduce((a, b) => a + b) / 3;
expect(avgLast3).toBeLessThan(avgFirst3 * 2); // Max 2x slower
});
});
Advanced Patterns
Test Data Management
// tests/helpers/api-test-helper.ts
export class APITestHelper {
private createdResources: Map<string, string[]> = new Map();
constructor(private apiContext: APIRequestContext) {}
async createUser(overrides?: Partial<User>): Promise<User> {
const response = await this.apiContext.post('/api/users', {
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: 'SecurePass123!',
...overrides,
},
});
const user = await response.json();
// Track for cleanup
if (!this.createdResources.has('users')) {
this.createdResources.set('users', []);
}
this.createdResources.get('users')!.push(user.id);
return user;
}
async cleanupTestData(): Promise<void> {
// Clean up in reverse order
for (const [resource, ids] of this.createdResources) {
for (const id of ids) {
await this.apiContext.delete(`/api/${resource}/${id}`);
}
}
this.createdResources.clear();
}
}
// Usage
test.describe('User Projects', () => {
let helper: APITestHelper;
test.beforeEach(async ({ request }) => {
helper = new APITestHelper(request);
});
test.afterEach(async () => {
await helper.cleanupTestData();
});
test('user can create project', async () => {
const user = await helper.createUser();
// Test logic...
});
});
Retry and Resilience Testing
// tests/api/resilience.spec.ts
test.describe('API Resilience', () => {
test('retries on transient failures', async ({ request }) => {
let attempts = 0;
let response;
// Retry up to 3 times
for (let i = 0; i < 3; i++) {
attempts++;
response = await request.get('/api/users');
if (response.ok()) {
break;
}
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, i)));
}
expect(response!.ok()).toBeTruthy();
console.log(`Succeeded after ${attempts} attempts`);
});
test('handles timeouts gracefully', async ({ request }) => {
const response = await request.get('/api/slow-endpoint', {
timeout: 5000, // 5 second timeout
});
// Should either succeed or timeout, not hang forever
expect([200, 408, 504]).toContain(response.status());
});
});
CI/CD Integration
# .github/workflows/api-tests.yml
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npx playwright install
# Run API tests
- name: Run API tests
env:
API_BASE_URL: ${{ secrets.API_BASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: npm run test:api
# Generate report
- name: Generate API test report
if: always()
run: npm run test:api:report
# Upload results
- uses: actions/upload-artifact@v4
if: always()
with:
name: api-test-results
path: api-test-results/
Best Practices Summary
| Practice | Description | Benefit |
|---|---|---|
| Test Happy & Unhappy Paths | Test both success and failure scenarios | Comprehensive coverage |
| Validate Response Schemas | Use JSON Schema or OpenAPI | Catch contract violations |
| Test Authentication | Verify auth required, roles work | Security |
| Test Rate Limiting | Ensure rate limits work | Prevent abuse |
| Clean Up Test Data | Delete created resources after tests | No pollution |
| Use Factories | Generate test data dynamically | Avoid conflicts |
| Test Performance | Measure response times | Catch regressions |
| Contract Testing | Verify API matches spec | Documentation accuracy |
Conclusion: API Testing for Confidence
Comprehensive API testing provides:
- Fast Feedback: Tests run in milliseconds
- Stability: No UI flakiness
- Coverage: Business logic validation
- Documentation: Tests serve as examples
- Confidence: Deploy knowing APIs work
Build a robust API test suite and catch bugs before they reach the UI.
Supercharge Your API Testing with ScanlyApp
ScanlyApp provides advanced API testing features including automated contract validation, performance monitoring, and comprehensive reporting.
Start Your Free Trial and elevate your API testing today.
Related articles: Also see applying these API testing best practices to GraphQL schemas, contract testing as the complementary API testing strategy, and layering security testing on top of your functional API test suite.
