API Testing
Learn how to test REST and GraphQL APIs effectively using modern tools like Supertest, MSW (Mock Service Worker), Postman, and Pact for contract testing.
What is API Testing?
API testing validates the functionality, reliability, performance, and security of Application Programming Interfaces (APIs). Unlike UI testing, API testing focuses on the business logic layer, ensuring data exchange between systems works correctly.
Benefits of API Testing
- Early Detection: Catch issues before UI implementation
- Faster Execution: No browser rendering overhead
- Better Coverage: Test edge cases and error scenarios easily
- Independent: Test backend logic without frontend dependencies
- Stability: Less brittle than UI tests
API tests are typically 10-100x faster than UI tests and can catch bugs earlier in the development cycle, making them essential for continuous integration pipelines.
Testing REST APIs
REST (Representational State Transfer) APIs are the most common API architecture. Testing REST APIs involves validating HTTP methods, status codes, headers, and response bodies.
Key Testing Aspects
| Aspect | Description | Example |
|---|---|---|
| HTTP Methods | GET, POST, PUT, PATCH, DELETE | GET /users/123 |
| Status Codes | 2xx success, 4xx client errors, 5xx server errors | 200 OK, 404 Not Found |
| Headers | Content-Type, Authorization, etc. | Content-Type: application/json |
| Request Body | JSON, XML, form data | { "name": "John" } |
| Response Body | Data structure and values | Validate schema and data |
| Authentication | Bearer tokens, API keys, OAuth | Authorization: Bearer token |
Using Supertest for Node.js
Supertest is a high-level HTTP assertion library for testing Node.js HTTP servers. It works seamlessly with Express, Koa, and other frameworks.
Installation
npm install --save-dev supertest @types/supertestBasic REST API Testing
import request from 'supertest';
import { app } from '../src/app';
describe('User API', () => {
describe('GET /api/users', () => {
it('should return all users', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body.length).toBeGreaterThan(0);
});
it('should filter users by role', async () => {
const response = await request(app)
.get('/api/users')
.query({ role: 'admin' })
.expect(200);
expect(response.body.every((user: any) => user.role === 'admin')).toBe(true);
});
});
describe('GET /api/users/:id', () => {
it('should return a specific user', async () => {
const userId = '123';
const response = await request(app)
.get(`/api/users/${userId}`)
.expect(200);
expect(response.body).toHaveProperty('id', userId);
expect(response.body).toHaveProperty('name');
expect(response.body).toHaveProperty('email');
});
it('should return 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/users/999999')
.expect(404);
expect(response.body).toHaveProperty('error');
});
});
describe('POST /api/users', () => {
it('should create a new user', async () => {
const newUser = {
name: 'Jane Doe',
email: 'jane@example.com',
role: 'user'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.set('Authorization', 'Bearer valid-token')
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(newUser.name);
expect(response.body.email).toBe(newUser.email);
});
it('should validate required fields', async () => {
const invalidUser = {
name: 'John'
// Missing email
};
const response = await request(app)
.post('/api/users')
.send(invalidUser)
.set('Authorization', 'Bearer valid-token')
.expect(400);
expect(response.body).toHaveProperty('errors');
expect(response.body.errors).toContain('email is required');
});
it('should reject unauthorized requests', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'test@example.com' })
.expect(401);
});
});
describe('PUT /api/users/:id', () => {
it('should update an existing user', async () => {
const userId = '123';
const updates = {
name: 'Updated Name',
email: 'updated@example.com'
};
const response = await request(app)
.put(`/api/users/${userId}`)
.send(updates)
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body.name).toBe(updates.name);
expect(response.body.email).toBe(updates.email);
});
});
describe('DELETE /api/users/:id', () => {
it('should delete a user', async () => {
const userId = '123';
await request(app)
.delete(`/api/users/${userId}`)
.set('Authorization', 'Bearer valid-token')
.expect(204);
// Verify deletion
await request(app)
.get(`/api/users/${userId}`)
.expect(404);
});
});
});Always test error scenarios (4xx, 5xx responses) as thoroughly as success scenarios. Many bugs hide in edge cases and error handling.
Advanced Supertest Patterns
// Testing file uploads
describe('POST /api/uploads', () => {
it('should upload a file', async () => {
const response = await request(app)
.post('/api/uploads')
.attach('file', 'test/fixtures/sample.pdf')
.field('description', 'Test file')
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body).toHaveProperty('fileUrl');
});
});
// Testing with cookies
describe('Authentication', () => {
it('should set session cookie on login', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'password' })
.expect(200);
expect(response.headers['set-cookie']).toBeDefined();
});
});
// Testing redirects
describe('GET /api/redirect', () => {
it('should redirect to new location', async () => {
await request(app)
.get('/api/redirect')
.expect(302)
.expect('Location', '/api/new-location');
});
});
// Testing with custom headers
describe('API Versioning', () => {
it('should handle API version headers', async () => {
const response = await request(app)
.get('/api/users')
.set('Accept', 'application/vnd.api.v2+json')
.expect(200);
expect(response.body.version).toBe('2.0');
});
});Testing GraphQL APIs
GraphQL provides a query language for APIs with a type system. Testing GraphQL requires different strategies than REST.
GraphQL Testing Fundamentals
import request from 'supertest';
import { app } from '../src/app';
describe('GraphQL API', () => {
const endpoint = '/graphql';
describe('Queries', () => {
it('should fetch user by ID', async () => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
}
}
}
`;
const variables = { id: '123' };
const response = await request(app)
.post(endpoint)
.send({ query, variables })
.set('Content-Type', 'application/json')
.expect(200);
expect(response.body.data.user).toBeDefined();
expect(response.body.data.user.id).toBe('123');
expect(response.body.data.user.posts).toBeInstanceOf(Array);
expect(response.body.errors).toBeUndefined();
});
it('should handle query errors gracefully', async () => {
const query = `
query {
user(id: "invalid") {
id
name
}
}
`;
const response = await request(app)
.post(endpoint)
.send({ query })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('User not found');
});
});
describe('Mutations', () => {
it('should create a new post', async () => {
const mutation = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
author {
id
name
}
}
}
`;
const variables = {
input: {
title: 'Test Post',
content: 'This is a test post',
authorId: '123'
}
};
const response = await request(app)
.post(endpoint)
.send({ mutation, variables })
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body.data.createPost).toBeDefined();
expect(response.body.data.createPost.title).toBe('Test Post');
expect(response.body.errors).toBeUndefined();
});
it('should validate mutation inputs', async () => {
const mutation = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
}
}
`;
const variables = {
input: {
title: '', // Invalid: empty title
content: 'Test'
}
};
const response = await request(app)
.post(endpoint)
.send({ mutation, variables })
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body.errors).toBeDefined();
});
});
describe('Subscriptions', () => {
it('should test subscription schema', async () => {
// Note: Testing actual subscriptions requires WebSocket support
const query = `
query IntrospectSubscription {
__type(name: "Subscription") {
fields {
name
type {
name
}
}
}
}
`;
const response = await request(app)
.post(endpoint)
.send({ query })
.expect(200);
const subscriptionFields = response.body.data.__type.fields;
expect(subscriptionFields.some((f: any) => f.name === 'postCreated')).toBe(true);
});
});
});GraphQL returns 200 OK even for errors. Always check response.body.errors in addition to the status code.
API Mocking with MSW
Mock Service Worker (MSW) intercepts HTTP requests at the network level, providing realistic API mocking for both testing and development.
Installation
npm install --save-dev mswSetting Up MSW
// src/mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
// Mock REST API
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' }
])
);
}),
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
if (id === '999') {
return res(
ctx.status(404),
ctx.json({ error: 'User not found' })
);
}
return res(
ctx.status(200),
ctx.json({
id,
name: 'John Doe',
email: 'john@example.com'
})
);
}),
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
if (!body.email) {
return res(
ctx.status(400),
ctx.json({ errors: ['email is required'] })
);
}
return res(
ctx.status(201),
ctx.json({
id: '123',
...body
})
);
}),
// Mock with authentication
rest.delete('/api/users/:id', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res(
ctx.status(401),
ctx.json({ error: 'Unauthorized' })
);
}
return res(ctx.status(204));
})
];// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);Using MSW in Tests
// src/setupTests.ts
import { server } from './mocks/server';
// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Clean up after all tests
afterAll(() => server.close());// src/api.test.ts
import { rest } from 'msw';
import { server } from './mocks/server';
import { fetchUser, createUser } from './api';
describe('API Client', () => {
it('should fetch user successfully', async () => {
const user = await fetchUser('1');
expect(user.id).toBe('1');
expect(user.name).toBe('John Doe');
});
it('should handle 404 errors', async () => {
await expect(fetchUser('999')).rejects.toThrow('User not found');
});
it('should create a user', async () => {
const newUser = {
name: 'New User',
email: 'new@example.com'
};
const created = await createUser(newUser);
expect(created.id).toBeDefined();
expect(created.name).toBe(newUser.name);
});
// Override handler for specific test
it('should handle server errors', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ error: 'Internal server error' })
);
})
);
await expect(fetchUser('1')).rejects.toThrow('Internal server error');
});
// Simulate network delay
it('should handle slow responses', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(
ctx.delay(2000),
ctx.json({ id: '1', name: 'John Doe' })
);
})
);
const start = Date.now();
await fetchUser('1');
const duration = Date.now() - start;
expect(duration).toBeGreaterThanOrEqual(2000);
});
});MSW works in both Node.js (for tests) and browsers (for development). You can use the same handlers for testing and local development without a real backend.
GraphQL Mocking with MSW
import { graphql } from 'msw';
export const graphqlHandlers = [
graphql.query('GetUser', (req, res, ctx) => {
const { id } = req.variables;
return res(
ctx.data({
user: {
id,
name: 'John Doe',
email: 'john@example.com',
posts: [
{ id: '1', title: 'First Post' },
{ id: '2', title: 'Second Post' }
]
}
})
);
}),
graphql.mutation('CreatePost', (req, res, ctx) => {
const { input } = req.variables;
if (!input.title) {
return res(
ctx.errors([
{ message: 'Title is required' }
])
);
}
return res(
ctx.data({
createPost: {
id: '123',
...input
}
})
);
})
];Postman and Automated Testing
Postman is a popular API development platform that includes powerful testing capabilities.
Postman Tests
// Test for status code
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
// Test response time
pm.test("Response time is less than 500ms", function () {
pm.expect(pm.response.responseTime).to.be.below(500);
});
// Test response body
pm.test("Response has correct structure", function () {
const jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('id');
pm.expect(jsonData).to.have.property('name');
pm.expect(jsonData.email).to.match(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/);
});
// Test headers
pm.test("Content-Type is application/json", function () {
pm.response.to.have.header("Content-Type", "application/json");
});
// Save data for next request
pm.test("Save user ID", function () {
const jsonData = pm.response.json();
pm.environment.set("userId", jsonData.id);
});
// Test array length
pm.test("Returns at least 5 users", function () {
const jsonData = pm.response.json();
pm.expect(jsonData).to.be.an('array');
pm.expect(jsonData.length).to.be.at.least(5);
});
// Schema validation
const schema = {
type: "object",
required: ["id", "name", "email"],
properties: {
id: { type: "string" },
name: { type: "string" },
email: { type: "string", format: "email" }
}
};
pm.test("Schema is valid", function () {
pm.response.to.have.jsonSchema(schema);
});Running Postman Tests with Newman
# Install Newman
npm install -g newman
# Run collection
newman run collection.json -e environment.json
# Generate HTML report
newman run collection.json --reporters html --reporter-html-export report.html
# Run in CI/CD
newman run collection.json --reporters cli,json --reporter-json-export results.jsonNewman is Postman’s CLI tool, perfect for integrating API tests into CI/CD pipelines.
Contract Testing with Pact
Contract testing ensures that services can communicate with each other. Pact enables consumer-driven contract testing.
Installation
npm install --save-dev @pact-foundation/pactConsumer Test
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { fetchUser } from './userService';
const { like, eachLike } = MatchersV3;
describe('User Service Contract', () => {
const provider = new PactV3({
consumer: 'UserWebApp',
provider: 'UserAPI',
dir: './pacts'
});
it('should fetch a user by ID', async () => {
await provider
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/api/users/123',
headers: {
Accept: 'application/json'
}
})
.willRespondWith({
status: 200,
headers: {
'Content-Type': 'application/json'
},
body: like({
id: '123',
name: 'John Doe',
email: 'john@example.com',
posts: eachLike({
id: '1',
title: 'Post Title'
})
})
})
.executeTest(async (mockServer) => {
const user = await fetchUser('123', mockServer.url);
expect(user.id).toBe('123');
expect(user.name).toBe('John Doe');
expect(user.posts).toHaveLength(1);
});
});
it('should handle user not found', async () => {
await provider
.given('user 999 does not exist')
.uponReceiving('a request for user 999')
.withRequest({
method: 'GET',
path: '/api/users/999'
})
.willRespondWith({
status: 404,
body: like({
error: 'User not found'
})
})
.executeTest(async (mockServer) => {
await expect(
fetchUser('999', mockServer.url)
).rejects.toThrow('User not found');
});
});
});Provider Verification
import { Verifier } from '@pact-foundation/pact';
import { app } from './app';
describe('Pact Verification', () => {
it('should validate the expectations of UserWebApp', async () => {
const opts = {
provider: 'UserAPI',
providerBaseUrl: 'http://localhost:3000',
pactUrls: ['./pacts/userwebapp-userapi.json'],
stateHandlers: {
'user 123 exists': async () => {
// Setup: Create user 123 in test database
await setupUser({ id: '123', name: 'John Doe' });
},
'user 999 does not exist': async () => {
// Setup: Ensure user 999 doesn't exist
await deleteUser('999');
}
}
};
await new Verifier(opts).verifyProvider();
});
});Contract testing complements but doesn’t replace integration testing. Use both for comprehensive API validation.
Best Practices
1. Test Pyramid for APIs
┌─────────┐
│ E2E │ ← Few end-to-end tests
├─────────┤
│ API │ ← Many API/integration tests
│ Tests │
├─────────┤
│ Unit │ ← Most unit tests
│ Tests │
└─────────┘2. Test Structure
describe('Resource API', () => {
describe('Happy Paths', () => {
// Test successful scenarios
});
describe('Error Cases', () => {
// Test validation, not found, server errors
});
describe('Edge Cases', () => {
// Test boundary conditions, special characters, etc.
});
describe('Security', () => {
// Test authentication, authorization, input sanitization
});
});3. Use Test Fixtures
// fixtures/users.ts
export const validUser = {
name: 'John Doe',
email: 'john@example.com',
role: 'user'
};
export const adminUser = {
name: 'Admin User',
email: 'admin@example.com',
role: 'admin'
};
// In tests
import { validUser } from './fixtures/users';
it('should create a user', async () => {
const response = await request(app)
.post('/api/users')
.send(validUser)
.expect(201);
});4. Test Authentication Flows
describe('Authentication', () => {
let authToken: string;
beforeAll(async () => {
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'password' });
authToken = response.body.token;
});
it('should access protected route with token', async () => {
await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
});
});5. Clean Up Test Data
afterEach(async () => {
// Clean up database
await cleanupTestData();
});Tools Comparison
| Tool | Best For | Learning Curve | Ecosystem |
|---|---|---|---|
| Supertest | Node.js integration tests | Easy | Express, Koa, Fastify |
| MSW | API mocking, dev & test | Moderate | Framework agnostic |
| Postman/Newman | Manual & automated testing | Easy | Standalone |
| Pact | Contract testing, microservices | Steep | Multi-language |
| Jest + fetch | Simple HTTP tests | Easy | Any framework |
| Cypress | E2E with API testing | Easy | Browser-based |
Related Topics
- End-to-End Testing - Full application testing
- CI/CD Integration - Running tests in pipelines
- Best Practices - Testing guidelines
- Performance Testing - Load and stress testing