What You’ll Learn in This Tutorial
- Setting up Jest and Supertest
- Testing GET endpoints
- Testing POST endpoints
- Testing APIs that require authentication
- Mocking databases
- Testing best practices
Prerequisites: Node.js 18 or higher with npm/yarn/pnpm installed. Basic knowledge of Express will help with understanding.
What is Software Testing? Why is it Necessary?
History of Testing
The concept of software testing dates back to the 1950s, but modern Test-Driven Development (TDD) was systematized by Kent Beck in the late 1990s.
“Testing is not about measuring quality. Testing is about building quality in.” — Kent Beck
Why Write Tests
- Prevent Regression: Early detection of breaking existing functionality
- Documentation: Tests demonstrate how code should be used
- Confidence in Refactoring: Tests allow bold improvements
- Design Improvement: Testable code tends to have good design
The Test Pyramid
The “Test Pyramid” proposed by Martin Fowler is a model showing the types and balance of tests:
flowchart TB
subgraph Pyramid["Test Pyramid"]
E2E["E2E Tests (Few)<br/>Browser automation"]
Integration["Integration Tests (Moderate)<br/>API tests, component tests"]
Unit["Unit Tests (Many)<br/>Functions, classes"]
end
E2E --- Integration
Integration --- Unit
| Type | Execution Speed | Maintenance Cost | Reliability | Coverage |
|---|---|---|---|---|
| E2E | Slow | High | Fragile | Wide |
| Integration | Moderate | Moderate | Moderate | Moderate |
| Unit | Fast | Low | Stable | Narrow |
Reference: Martin Fowler - Test Pyramid
Where API Testing Fits
API testing is classified as “integration testing” and offers the following benefits:
- Not affected by UI changes
- Faster and more stable than E2E
- Tests actual HTTP requests
- Can test backend without frontend
Step 1: Project Setup
First, create a simple Express application to test.
Creating the Project
mkdir api-testing-tutorial
cd api-testing-tutorial
npm init -y
npm install express
npm install -D jest supertest @types/jest @types/supertest typescript ts-jest
package.json (scripts section)
{
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Jest Configuration
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.test.ts'
],
// Setup before and after tests
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
// Coverage thresholds (optional)
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Official Documentation: Jest Configuration
Step 2: Create the API to Test
src/app.ts
import express, { Express, Request, Response, NextFunction } from 'express';
const app: Express = express();
app.use(express.json());
// In-memory data store
interface User {
id: number;
name: string;
email: string;
}
let users: User[] = [
{ id: 1, name: 'Taro Tanaka', email: 'tanaka@example.com' },
{ id: 2, name: 'Hanako Sato', email: 'sato@example.com' }
];
// Error handling middleware
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
};
// GET /api/users - User list
app.get('/api/users', (req: Request, res: Response) => {
res.json(users);
});
// GET /api/users/:id - User details
app.get('/api/users/:id', (req: Request, res: Response) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// POST /api/users - Create user
app.post('/api/users', (req: Request, res: Response) => {
const { name, email } = req.body;
// Validation
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
if (!email.includes('@')) {
return res.status(400).json({ error: 'Invalid email format' });
}
const newUser: User = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
// PUT /api/users/:id - Update user
app.put('/api/users/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
const { name, email } = req.body;
users[userIndex] = { ...users[userIndex], name, email };
res.json(users[userIndex]);
});
// DELETE /api/users/:id - Delete user
app.delete('/api/users/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users.splice(userIndex, 1);
res.status(204).send();
});
app.use(errorHandler);
// Function to reset data for testing
export const resetUsers = () => {
users = [
{ id: 1, name: 'Taro Tanaka', email: 'tanaka@example.com' },
{ id: 2, name: 'Hanako Sato', email: 'sato@example.com' }
];
};
export default app;
Step 3: Basic GET Tests
src/app.test.ts
import request from 'supertest';
import app, { resetUsers } from './app';
describe('Users API', () => {
// Reset data before each test
beforeEach(() => {
resetUsers();
});
// GET /api/users tests
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 return users with correct properties', async () => {
const response = await request(app)
.get('/api/users')
.expect(200);
const user = response.body[0];
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('email');
});
});
// GET /api/users/:id tests
describe('GET /api/users/:id', () => {
it('should return a user by id', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body.id).toBe(1);
expect(response.body).toHaveProperty('name');
});
it('should return 404 for non-existent user', async () => {
const response = await request(app)
.get('/api/users/999')
.expect(404);
expect(response.body.error).toBe('User not found');
});
it('should handle invalid id format', async () => {
const response = await request(app)
.get('/api/users/invalid')
.expect(404);
});
});
});
Running Tests
npm test
# Example output
# PASS src/app.test.ts
# Users API
# GET /api/users
# ✓ should return all users (25 ms)
# ✓ should return users with correct properties (8 ms)
# GET /api/users/:id
# ✓ should return a user by id (5 ms)
# ✓ should return 404 for non-existent user (4 ms)
Step 4: POST Tests
Add tests for data creation.
describe('POST /api/users', () => {
it('should create a new user', async () => {
const newUser = {
name: 'Jiro Yamada',
email: 'yamada@example.com'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toMatchObject(newUser);
expect(response.body).toHaveProperty('id');
});
it('should return 400 when name is missing', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com' })
.expect(400);
expect(response.body.error).toContain('required');
});
it('should return 400 when email is missing', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Test User' })
.expect(400);
expect(response.body.error).toContain('required');
});
it('should return 400 for invalid email format', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Test User', email: 'invalid-email' })
.expect(400);
expect(response.body.error).toContain('format');
});
});
Step 5: PUT/DELETE Tests
describe('PUT /api/users/:id', () => {
it('should update an existing user', async () => {
const updatedData = {
name: 'Taro Tanaka (Updated)',
email: 'tanaka-updated@example.com'
};
const response = await request(app)
.put('/api/users/1')
.send(updatedData)
.expect(200);
expect(response.body.name).toBe(updatedData.name);
expect(response.body.email).toBe(updatedData.email);
});
it('should return 404 for non-existent user', async () => {
await request(app)
.put('/api/users/999')
.send({ name: 'test', email: 'test@example.com' })
.expect(404);
});
});
describe('DELETE /api/users/:id', () => {
it('should delete an existing user', async () => {
await request(app)
.delete('/api/users/1')
.expect(204);
// Verify deletion
await request(app)
.get('/api/users/1')
.expect(404);
});
it('should return 404 for non-existent user', async () => {
await request(app)
.delete('/api/users/999')
.expect(404);
});
});
Step 6: Test Patterns and Best Practices
AAA Pattern (Arrange-Act-Assert)
Structure tests in three phases:
it('should create a new user', async () => {
// Arrange: Prepare test data
const newUser = {
name: 'Jiro Yamada',
email: 'yamada@example.com'
};
// Act: Execute the test target
const response = await request(app)
.post('/api/users')
.send(newUser);
// Assert: Verify results
expect(response.status).toBe(201);
expect(response.body).toMatchObject(newUser);
});
Maintaining Test Independence
describe('Users API', () => {
// Reset data before each test
beforeEach(() => {
resetUsers();
});
// Cleanup after each test (if needed)
afterEach(() => {
// Reset mocks, etc.
jest.clearAllMocks();
});
// Cleanup after all tests
afterAll(async () => {
// Close DB connections, etc.
});
});
Descriptive Test Names
// Good examples: Specific and clear intent
it('should return 404 when user does not exist', () => {});
it('should create user and return 201 status', () => {});
it('should validate email format and reject invalid emails', () => {});
// Bad examples: Unclear intent
it('test1', () => {});
it('works', () => {});
it('success', () => {});
Naming Convention: The “should + expected behavior” format is common.
Boundary Value Testing
describe('Validation', () => {
it('should accept name with 1 character (minimum)', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'A', email: 'test@example.com' })
.expect(201);
});
it('should accept name with 100 characters (maximum)', async () => {
const longName = 'A'.repeat(100);
const response = await request(app)
.post('/api/users')
.send({ name: longName, email: 'test@example.com' })
.expect(201);
});
it('should reject empty name', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '', email: 'test@example.com' })
.expect(400);
});
});
Step 7: Testing APIs with Authentication
Example of testing an API that uses JWT tokens:
describe('Protected API', () => {
const validToken = 'Bearer valid-jwt-token';
const invalidToken = 'Bearer invalid-token';
it('should return 401 without authorization header', async () => {
await request(app)
.get('/api/protected/resource')
.expect(401);
});
it('should return 401 with invalid token', async () => {
await request(app)
.get('/api/protected/resource')
.set('Authorization', invalidToken)
.expect(401);
});
it('should return 200 with valid token', async () => {
await request(app)
.get('/api/protected/resource')
.set('Authorization', validToken)
.expect(200);
});
});
Step 8: Using Mocks
Mock external dependencies (databases, external APIs).
Mocking Databases
// src/services/userService.ts
import { db } from '../db';
export const userService = {
findAll: async () => db.users.findMany(),
findById: async (id: number) => db.users.findUnique({ where: { id } }),
create: async (data: { name: string; email: string }) => db.users.create({ data })
};
// src/services/userService.test.ts
import { userService } from './userService';
import { db } from '../db';
// Mock the db module
jest.mock('../db', () => ({
db: {
users: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn()
}
}
}));
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return all users', async () => {
const mockUsers = [
{ id: 1, name: 'User 1', email: 'user1@example.com' }
];
(db.users.findMany as jest.Mock).mockResolvedValue(mockUsers);
const result = await userService.findAll();
expect(db.users.findMany).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockUsers);
});
});
Mocking External APIs
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('External API integration', () => {
it('should fetch data from external API', async () => {
const mockData = { data: { id: 1, title: 'Test' } };
mockedAxios.get.mockResolvedValue(mockData);
const result = await fetchExternalData();
expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/data');
expect(result).toEqual(mockData.data);
});
it('should handle API errors', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
await expect(fetchExternalData()).rejects.toThrow('Network Error');
});
});
Test Coverage
Generating Coverage Reports
npm test -- --coverage
---------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
---------------------|---------|----------|---------|---------|
All files | 95.24 | 88.89 | 100 | 95.24 |
app.ts | 95.24 | 88.89 | 100 | 95.24 |
---------------------|---------|----------|---------|---------|
Types of Coverage
| Type | Description |
|---|---|
| Statements | Statement execution rate |
| Branches | Branch coverage rate |
| Functions | Function call rate |
| Lines | Line execution rate |
Note: 100% coverage is not the goal. Prioritize testing important business logic.
Common Mistakes and Anti-Patterns
1. Test Interdependence
// Bad example: Depends on test order
it('should create user', async () => {
await request(app).post('/api/users').send({ name: 'Test', email: 'test@example.com' });
});
it('should have 3 users', async () => {
// Assumes the above test has run
const response = await request(app).get('/api/users');
expect(response.body.length).toBe(3);
});
// Good example: Each test is independent
beforeEach(() => {
resetUsers(); // Reset data
});
it('should create user', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'test@example.com' });
expect(response.status).toBe(201);
});
2. Testing Implementation Details
// Bad example: Testing internal implementation
it('should call database with correct SQL', async () => {
await userService.findById(1);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = 1');
});
// Good example: Testing behavior
it('should return user by id', async () => {
const user = await userService.findById(1);
expect(user.id).toBe(1);
});
3. Over-Mocking
// Bad example: Mocking everything (test becomes meaningless)
jest.mock('./userService');
jest.mock('./database');
jest.mock('./validator');
// Good example: Only mock external dependencies
jest.mock('./externalApiClient');
Summary
Writing API tests provides the following benefits:
- Prevent regression (breaking existing functionality)
- Document API specifications in code
- Confidence when refactoring
- Automated verification in CI/CD pipelines
Start with simple GET/POST tests and gradually expand coverage.
Reference Links
Official Documentation
- Jest Official Documentation - Test framework
- Supertest GitHub - HTTP assertion library
- Testing Library - Testing best practices
Best Practices & Articles
- Martin Fowler - Test Pyramid - Test pyramid explanation
- Google Testing Blog - Google’s testing blog
- Kent C. Dodds - Testing JavaScript - Comprehensive JavaScript testing guide
Books
- “Test Driven Development” (by Kent Beck) - The original TDD book
- “Refactoring” (by Martin Fowler) - The relationship between testing and refactoring
Tools
- Jest - JavaScript test framework
- Vitest - Fast test framework for Vite
- Postman - API development and testing tool
- Insomnia - REST/GraphQL client