API Route Testing Skill
This skill provides framework-agnostic guidance for testing HTTP API routes and endpoints across any backend framework (Express, Next.js API Routes, FastAPI, Django REST, Flask, etc.).
Core Testing Principles
- Test Types for API Routes
Unit Tests
-
Test individual route handlers in isolation
-
Mock dependencies (database, external APIs)
-
Fast execution (< 50ms per test)
-
Focus on business logic
Integration Tests
-
Test full request/response cycle
-
Real database (test instance)
-
Authentication flow included
-
Slower but more comprehensive
End-to-End Tests
-
Test from client perspective
-
Full authentication flow
-
Real services (or close replicas)
-
Most realistic, slowest execution
- Authentication Testing Patterns
JWT Cookie Authentication
// Common pattern across frameworks describe('Protected Route Tests', () => { let authCookie: string;
beforeEach(async () => { // Login and get JWT cookie const loginResponse = await request(app) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'password123' });
authCookie = loginResponse.headers['set-cookie'][0];
});
it('should access protected route with valid cookie', async () => { const response = await request(app) .get('/api/protected/resource') .set('Cookie', authCookie);
expect(response.status).toBe(200);
});
it('should reject access without cookie', async () => { const response = await request(app) .get('/api/protected/resource');
expect(response.status).toBe(401);
}); });
JWT Bearer Token Authentication
describe('Bearer Token Auth', () => { let token: string;
beforeEach(async () => { const response = await request(app) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'password123' });
token = response.body.token;
});
it('should authenticate with bearer token', async () => {
const response = await request(app)
.get('/api/protected/resource')
.set('Authorization', Bearer ${token});
expect(response.status).toBe(200);
}); });
- HTTP Method Testing
GET Requests
describe('GET /api/users', () => { it('should return paginated users', async () => { const response = await request(app) .get('/api/users?page=1&limit=10');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('pagination');
expect(Array.isArray(response.body.data)).toBe(true);
});
it('should filter users by query params', async () => { const response = await request(app) .get('/api/users?role=admin');
expect(response.status).toBe(200);
expect(response.body.data.every(u => u.role === 'admin')).toBe(true);
}); });
POST Requests
describe('POST /api/users', () => { it('should create new user with valid data', async () => { const newUser = { name: 'John Doe', email: 'john@example.com', role: 'user' };
const response = await request(app)
.post('/api/users')
.set('Cookie', authCookie)
.send(newUser);
expect(response.status).toBe(201);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
it('should reject invalid data', async () => { const invalidUser = { name: 'John Doe' // Missing required email field };
const response = await request(app)
.post('/api/users')
.set('Cookie', authCookie)
.send(invalidUser);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('errors');
}); });
PUT/PATCH Requests
describe('PATCH /api/users/:id', () => { it('should update user fields', async () => { const updates = { name: 'Jane Doe' };
const response = await request(app)
.patch('/api/users/123')
.set('Cookie', authCookie)
.send(updates);
expect(response.status).toBe(200);
expect(response.body.name).toBe('Jane Doe');
});
it('should return 404 for non-existent user', async () => { const response = await request(app) .patch('/api/users/999999') .set('Cookie', authCookie) .send({ name: 'Test' });
expect(response.status).toBe(404);
}); });
DELETE Requests
describe('DELETE /api/users/:id', () => { it('should delete user and return success', async () => { const response = await request(app) .delete('/api/users/123') .set('Cookie', authCookie);
expect(response.status).toBe(204);
});
it('should prevent unauthorized deletion', async () => { const response = await request(app) .delete('/api/users/123'); // No auth cookie
expect(response.status).toBe(401);
}); });
- Response Validation
Status Codes
describe('HTTP Status Codes', () => { it('200 OK - Successful GET', async () => { const response = await request(app).get('/api/users'); expect(response.status).toBe(200); });
it('201 Created - Successful POST', async () => { const response = await request(app).post('/api/users').send(validData); expect(response.status).toBe(201); });
it('204 No Content - Successful DELETE', async () => { const response = await request(app).delete('/api/users/123'); expect(response.status).toBe(204); });
it('400 Bad Request - Invalid input', async () => { const response = await request(app).post('/api/users').send({}); expect(response.status).toBe(400); });
it('401 Unauthorized - Missing auth', async () => { const response = await request(app).get('/api/protected'); expect(response.status).toBe(401); });
it('403 Forbidden - Insufficient permissions', async () => { const response = await request(app).delete('/api/admin/users/123').set('Cookie', userCookie); expect(response.status).toBe(403); });
it('404 Not Found - Non-existent resource', async () => { const response = await request(app).get('/api/users/999999'); expect(response.status).toBe(404); });
it('500 Internal Server Error - Server failure', async () => { // Test error handling mockDatabase.findOne.mockRejectedValue(new Error('DB Error')); const response = await request(app).get('/api/users/123'); expect(response.status).toBe(500); }); });
Response Schema Validation
describe('Response Schema', () => { it('should match expected schema', async () => { const response = await request(app).get('/api/users/123');
expect(response.body).toEqual({
id: expect.any(String),
name: expect.any(String),
email: expect.any(String),
role: expect.stringMatching(/^(user|admin)$/),
createdAt: expect.any(String),
updatedAt: expect.any(String)
});
}); });
- Error Handling Tests
describe('Error Handling', () => { it('should return structured error response', async () => { const response = await request(app) .post('/api/users') .send({ invalid: 'data' });
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: expect.any(String),
message: expect.any(String),
errors: expect.any(Array)
});
});
it('should handle database errors gracefully', async () => { mockDatabase.findOne.mockRejectedValue(new Error('Connection lost'));
const response = await request(app).get('/api/users/123');
expect(response.status).toBe(500);
expect(response.body.error).toBe('Internal Server Error');
});
it('should sanitize error messages in production', async () => { process.env.NODE_ENV = 'production';
const response = await request(app).get('/api/error-prone-route');
expect(response.status).toBe(500);
expect(response.body.message).not.toContain('stack trace');
expect(response.body.message).not.toContain('SQL');
}); });
- Test Setup and Teardown
describe('API Tests', () => { let testDatabase;
beforeAll(async () => { // Initialize test database testDatabase = await initTestDatabase(); });
afterAll(async () => { // Clean up test database await testDatabase.close(); });
beforeEach(async () => { // Seed test data await testDatabase.seed(); });
afterEach(async () => { // Clear test data await testDatabase.clear(); });
// Tests... });
Framework-Specific Testing Libraries
While this skill provides framework-agnostic patterns, here are common testing libraries per framework:
-
Express: supertest, jest, vitest
-
Next.js API Routes: @testing-library/react, next-test-api-route-handler
-
FastAPI: pytest, httpx
-
Django REST: django.test.TestCase, rest_framework.test
-
Flask: pytest, flask.testing
Best Practices
-
Use descriptive test names - Test names should describe the scenario and expected outcome
-
Test happy path and edge cases - Cover both success and failure scenarios
-
Isolate tests - Each test should be independent and not rely on other tests
-
Use realistic test data - Test data should mimic production data
-
Clean up after tests - Always reset state between tests
-
Mock external dependencies - Don't call real external APIs in tests
-
Test authentication edge cases - Expired tokens, invalid tokens, missing tokens
-
Validate response schemas - Ensure APIs return expected structure
-
Test rate limiting - Verify rate limits work correctly
-
Test CORS headers - Ensure CORS is configured correctly
Common Pitfalls
❌ Don't share state between tests
// Bad let userId; it('creates user', async () => { const response = await request(app).post('/api/users').send(userData); userId = response.body.id; // Shared state! });
it('deletes user', async () => {
await request(app).delete(/api/users/${userId}); // Depends on previous test
});
✅ Do create fresh state for each test
// Good it('creates user', async () => { const response = await request(app).post('/api/users').send(userData); expect(response.status).toBe(201); });
it('deletes user', async () => {
const user = await createTestUser();
const response = await request(app).delete(/api/users/${user.id});
expect(response.status).toBe(204);
});
Additional Resources
See the resources/ directory for more detailed guides:
-
http-testing-fundamentals.md
-
Deep dive into HTTP testing concepts
-
authentication-testing.md
-
Authentication strategies and edge cases
-
api-integration-testing.md
-
Integration testing patterns and tools
Quick Reference
Test Structure
describe('Resource Name', () => { describe('HTTP Method /path', () => { it('should describe expected behavior', async () => { // Arrange const testData = {...};
// Act
const response = await request(app)
.method('/path')
.set('Cookie', authCookie)
.send(testData);
// Assert
expect(response.status).toBe(expectedStatus);
expect(response.body).toMatchObject(expectedData);
});
}); });
Authentication Pattern
let authCookie: string;
beforeEach(async () => { const response = await request(app) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'password123' });
authCookie = response.headers['set-cookie'][0]; });
// Use authCookie in protected route tests .set('Cookie', authCookie)