Meta Description: Test your REST API thoroughly with unit tests, integration tests, and contract tests. Learn tools, patterns, and best practices for reliable APIs.
Keywords: api testing, integration testing, contract testing, unit testing, api test automation, rest api testing
Word Count: ~2,300 words
Your API works in development. But does it work in production? Under load? After a dependency changes?
Testing gives you confidence. Here's how to test REST APIs at every level.
The Testing Pyramid
┌─────────────┐
│ E2E Tests │ ← Few, slow, expensive
├─────────────┤
│ Integration │ ← Some, medium speed
│ Tests │
├─────────────┤
│ Unit Tests │ ← Many, fast, cheap
└─────────────┘
Write many unit tests, some integration tests, and few end-to-end tests.
Unit Tests
Unit tests verify individual functions in isolation.
Testing Validation Logic
// validation.js
function validatePet(data) {
const errors = [];
if (!data.name || data.name.trim().length === 0) {
errors.push({ field: 'name', message: 'Name is required' });
}
if (data.name && data.name.length > 100) {
errors.push({ field: 'name', message: 'Name must be 100 characters or less' });
}
const validSpecies = ['DOG', 'CAT', 'BIRD', 'RABBIT'];
if (!validSpecies.includes(data.species)) {
errors.push({ field: 'species', message: `Species must be one of: ${validSpecies.join(', ')}` });
}
if (data.age !== undefined && (data.age < 0 || data.age > 30)) {
errors.push({ field: 'age', message: 'Age must be between 0 and 30' });
}
return errors;
}
module.exports = { validatePet };
// validation.test.js
const { validatePet } = require('./validation');
describe('validatePet', () => {
test('valid pet passes validation', () => {
const pet = { name: 'Max', species: 'DOG', age: 3 };
const errors = validatePet(pet);
expect(errors).toHaveLength(0);
});
test('missing name fails validation', () => {
const pet = { species: 'DOG' };
const errors = validatePet(pet);
expect(errors).toContainEqual({
field: 'name',
message: 'Name is required'
});
});
test('name too long fails validation', () => {
const pet = { name: 'A'.repeat(101), species: 'DOG' };
const errors = validatePet(pet);
expect(errors).toContainEqual({
field: 'name',
message: 'Name must be 100 characters or less'
});
});
test('invalid species fails validation', () => {
const pet = { name: 'Max', species: 'DRAGON' };
const errors = validatePet(pet);
expect(errors).toContainEqual(
expect.objectContaining({ field: 'species' })
);
});
test('negative age fails validation', () => {
const pet = { name: 'Max', species: 'DOG', age: -1 };
const errors = validatePet(pet);
expect(errors).toContainEqual(
expect.objectContaining({ field: 'age' })
);
});
});
Testing Business Logic
// petService.test.js
const { PetService } = require('./petService');
const { MockPetRepository } = require('./mocks');
describe('PetService', () => {
let service;
let mockRepo;
beforeEach(() => {
mockRepo = new MockPetRepository();
service = new PetService(mockRepo);
});
test('getPet returns pet when found', async () => {
const pet = { id: '123', name: 'Max', species: 'DOG' };
mockRepo.findById.mockResolvedValue(pet);
const result = await service.getPet('123');
expect(result).toEqual(pet);
expect(mockRepo.findById).toHaveBeenCalledWith('123');
});
test('getPet throws NotFoundError when pet missing', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getPet('999')).rejects.toThrow('Pet 999 not found');
});
test('createPet validates input', async () => {
await expect(service.createPet({ name: '', species: 'DOG' }))
.rejects.toThrow('Name is required');
});
test('createPet saves valid pet', async () => {
const petData = { name: 'Max', species: 'DOG' };
const savedPet = { id: '123', ...petData };
mockRepo.create.mockResolvedValue(savedPet);
const result = await service.createPet(petData);
expect(result).toEqual(savedPet);
expect(mockRepo.create).toHaveBeenCalledWith(petData);
});
});
Integration Tests
Integration tests verify that components work together.
Testing API Endpoints
// pets.integration.test.js
const request = require('supertest');
const app = require('./app');
const db = require('./db');
describe('Pets API', () => {
beforeAll(async () => {
await db.connect();
await db.migrate();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.seed(); // Reset to known state
});
describe('GET /v1/pets', () => {
test('returns list of pets', async () => {
const response = await request(app)
.get('/v1/pets')
.set('Authorization', 'Bearer test-token')
.expect(200);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBeGreaterThan(0);
});
test('filters by species', async () => {
const response = await request(app)
.get('/v1/pets?species=DOG')
.set('Authorization', 'Bearer test-token')
.expect(200);
response.body.data.forEach(pet => {
expect(pet.species).toBe('DOG');
});
});
test('requires authentication', async () => {
await request(app)
.get('/v1/pets')
.expect(401);
});
});
describe('POST /v1/pets', () => {
test('creates a pet', async () => {
const petData = {
name: 'Bella',
species: 'CAT',
breed: 'Siamese'
};
const response = await request(app)
.post('/v1/pets')
.set('Authorization', 'Bearer test-token')
.send(petData)
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe('Bella');
expect(response.headers.location).toContain(response.body.id);
});
test('validates required fields', async () => {
const response = await request(app)
.post('/v1/pets')
.set('Authorization', 'Bearer test-token')
.send({ species: 'DOG' }) // Missing name
.expect(422);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: 'name' })
);
});
});
describe('DELETE /v1/pets/:id', () => {
test('deletes a pet', async () => {
const pet = await db.pets.create({ name: 'Max', species: 'DOG' });
await request(app)
.delete(`/v1/pets/${pet.id}`)
.set('Authorization', 'Bearer admin-token')
.expect(204);
const deleted = await db.pets.findById(pet.id);
expect(deleted).toBeNull();
});
test('returns 404 for non-existent pet', async () => {
await request(app)
.delete('/v1/pets/non-existent-id')
.set('Authorization', 'Bearer admin-token')
.expect(404);
});
});
});
Contract Tests
Contract tests verify that your API matches what clients expect.
Consumer-Driven Contract Testing with Pact
// consumer.test.js (Client side)
const { Pact } = require('@pact-foundation/pact');
const { PetStoreClient } = require('./petStoreClient');
const provider = new Pact({
consumer: 'PetAdoptionApp',
provider: 'PetStoreAPI',
port: 1234
});
describe('PetStore API Contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
describe('GET /v1/pets/:id', () => {
beforeEach(() => {
return provider.addInteraction({
state: 'pet 123 exists',
uponReceiving: 'a request for pet 123',
withRequest: {
method: 'GET',
path: '/v1/pets/123',
headers: { Authorization: 'Bearer token' }
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: '123',
name: 'Max',
species: 'DOG',
status: 'AVAILABLE'
}
}
});
});
test('returns pet data', async () => {
const client = new PetStoreClient('http://localhost:1234', 'token');
const pet = await client.getPet('123');
expect(pet.id).toBe('123');
expect(pet.name).toBe('Max');
});
});
});
// provider.test.js (Server side)
const { Verifier } = require('@pact-foundation/pact');
describe('PetStore API Provider', () => {
test('validates consumer contracts', async () => {
const verifier = new Verifier({
provider: 'PetStoreAPI',
providerBaseUrl: 'http://localhost:3000',
pactUrls: ['./pacts/PetAdoptionApp-PetStoreAPI.json'],
stateHandlers: {
'pet 123 exists': async () => {
await db.pets.create({ id: '123', name: 'Max', species: 'DOG' });
}
}
});
await verifier.verifyProvider();
});
});
Testing Error Responses
describe('Error handling', () => {
test('returns RFC 9457 error format', async () => {
const response = await request(app)
.get('/v1/pets/non-existent')
.set('Authorization', 'Bearer test-token')
.expect(404);
expect(response.body).toMatchObject({
type: expect.stringContaining('not-found'),
title: 'Not Found',
status: 404,
detail: expect.any(String)
});
});
test('returns validation errors', async () => {
const response = await request(app)
.post('/v1/pets')
.set('Authorization', 'Bearer test-token')
.send({ name: '', species: 'INVALID' })
.expect(422);
expect(response.body.errors).toBeInstanceOf(Array);
expect(response.body.errors.length).toBeGreaterThan(0);
});
});
Testing Authentication
describe('Authentication', () => {
test('rejects missing token', async () => {
await request(app)
.get('/v1/pets')
.expect(401);
});
test('rejects invalid token', async () => {
await request(app)
.get('/v1/pets')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
test('rejects expired token', async () => {
const expiredToken = jwt.sign(
{ userId: '123' },
JWT_SECRET,
{ expiresIn: '-1s' } // Already expired
);
await request(app)
.get('/v1/pets')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
Load Testing
// load-test.js with k6
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 10 }, // Ramp up to 10 users
{ duration: '1m', target: 50 }, // Ramp up to 50 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 response = http.get('https://api.petstoreapi.com/v1/pets', {
headers: { Authorization: 'Bearer test-token' }
});
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
CI/CD Integration
# .github/workflows/test.yml
name: API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:test@localhost/test
- name: Run contract tests
run: npm run test:contract
Summary
A solid API testing strategy includes:
- Unit tests: Fast, isolated, test business logic
- Integration tests: Test endpoints with real database
- Contract tests: Verify API matches client expectations
- Error tests: Verify error responses are correct
- Auth tests: Verify authentication and authorization
- Load tests: Verify performance under load
Start with unit and integration tests. Add contract tests when you have multiple consumers. Run load tests before major releases.
The Modern PetStore API demonstrates all these patterns. Use it as a reference when building your own test suite.