Express Testing Strategy
Testing is a crucial aspect of application development that ensures your Express.js applications work as expected. A well-thought-out testing strategy can save you hours of debugging and prevent bugs from reaching production.
Introduction to Testing Express Applications
Express.js applications can quickly grow in complexity as you add routes, middleware, and business logic. Without proper testing, it becomes increasingly difficult to make changes confidently without breaking existing functionality.
In this guide, we'll explore different testing approaches for Express applications, from simple unit tests to comprehensive end-to-end tests.
Why Test Your Express Applications?
- Catch bugs early: Identify issues before they reach production
- Refactor with confidence: Change code without breaking functionality
- Document behavior: Tests serve as living documentation
- Improve code quality: Writing testable code often leads to better architecture
- Facilitate collaboration: New team members can understand expected behavior
Types of Tests for Express Applications
1. Unit Tests
Unit tests focus on testing individual functions or components in isolation.
Example: Testing a Utility Function
Let's say we have a validation utility:
// utils/validators.js
function validateUser(user) {
if (!user.email || !user.email.includes('@')) {
return { valid: false, error: 'Invalid email' };
}
if (!user.password || user.password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters' };
}
return { valid: true };
}
module.exports = { validateUser };
Here's how you can test it using Jest:
// tests/utils/validators.test.js
const { validateUser } = require('../../utils/validators');
describe('validateUser function', () => {
test('should return valid for correct user data', () => {
const user = {
email: '[email protected]',
password: 'password123'
};
const result = validateUser(user);
expect(result.valid).toBe(true);
});
test('should return error for invalid email', () => {
const user = {
email: 'testexample.com',
password: 'password123'
};
const result = validateUser(user);
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid email');
});
test('should return error for short password', () => {
const user = {
email: '[email protected]',
password: 'pass'
};
const result = validateUser(user);
expect(result.valid).toBe(false);
expect(result.error).toContain('Password must be');
});
});
2. Route Handler Tests
These test route handlers independently from the Express framework.
Example: Testing a Route Handler
Consider this user controller:
// controllers/userController.js
const { validateUser } = require('../utils/validators');
const User = require('../models/user');
async function createUser(req, res) {
try {
const validation = validateUser(req.body);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
const existingUser = await User.findOne({ email: req.body.email });
if (existingUser) {
return res.status(409).json({ error: 'User already exists' });
}
const user = await User.create({
email: req.body.email,
password: req.body.password
});
return res.status(201).json({
id: user.id,
email: user.email
});
} catch (error) {
return res.status(500).json({ error: 'Server error' });
}
}
module.exports = { createUser };
Here's how to test it with mocks:
// tests/controllers/userController.test.js
const { createUser } = require('../../controllers/userController');
const { validateUser } = require('../../utils/validators');
const User = require('../../models/user');
// Mock dependencies
jest.mock('../../utils/validators');
jest.mock('../../models/user');
describe('createUser controller', () => {
let req;
let res;
beforeEach(() => {
req = {
body: {
email: '[email protected]',
password: 'password123'
}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
// Reset all mocks
jest.clearAllMocks();
});
test('should create user successfully', async () => {
// Setup mocks
validateUser.mockReturnValue({ valid: true });
User.findOne.mockResolvedValue(null);
User.create.mockResolvedValue({
id: '123',
email: '[email protected]'
});
// Execute
await createUser(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
id: '123',
email: '[email protected]'
});
});
test('should return 400 if validation fails', async () => {
// Setup mocks
validateUser.mockReturnValue({
valid: false,
error: 'Invalid email'
});
// Execute
await createUser(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Invalid email'
});
expect(User.findOne).not.toHaveBeenCalled();
expect(User.create).not.toHaveBeenCalled();
});
test('should return 409 if user already exists', async () => {
// Setup mocks
validateUser.mockReturnValue({ valid: true });
User.findOne.mockResolvedValue({
id: '123',
email: '[email protected]'
});
// Execute
await createUser(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
error: 'User already exists'
});
expect(User.create).not.toHaveBeenCalled();
});
});
3. Integration Tests
Integration tests verify that different parts of your application work together correctly. For Express apps, this often means testing the actual HTTP endpoints.
For this, we'll use Supertest, a library that helps test HTTP servers:
npm install --save-dev supertest
Example: Testing API Endpoints
Let's test a complete route:
// app.js
const express = require('express');
const { createUser } = require('./controllers/userController');
const app = express();
app.use(express.json());
app.post('/api/users', createUser);
module.exports = app;
Here's the test:
// tests/routes/user.test.js
const request = require('supertest');
const app = require('../../app');
const User = require('../../models/user');
// Mock the User model
jest.mock('../../models/user');
describe('User API endpoints', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /api/users', () => {
test('should create a new user', async () => {
// Mock database responses
User.findOne.mockResolvedValue(null);
User.create.mockResolvedValue({
id: '123',
email: '[email protected]'
});
// Make request
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'password123'
});
// Assert
expect(response.status).toBe(201);
expect(response.body).toEqual({
id: '123',
email: '[email protected]'
});
});
test('should return 400 for invalid data', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
password: 'short'
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
test('should return 409 if user already exists', async () => {
// Mock database response for existing user
User.findOne.mockResolvedValue({
id: '123',
email: '[email protected]'
});
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'password123'
});
expect(response.status).toBe(409);
expect(response.body).toHaveProperty('error', 'User already exists');
});
});
});
4. End-to-End Tests
End-to-end tests verify that the entire application works correctly, including database interactions.
For this level of testing, we can still use Supertest but with a real database connection:
// tests/e2e/users.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../../app');
const User = require('../../models/user');
// Use a separate test database
const testMongoUri = 'mongodb://localhost:27017/express_test_db';
describe('User API E2E Tests', () => {
// Connect to test database
beforeAll(async () => {
await mongoose.connect(testMongoUri);
});
// Clean up database between tests
beforeEach(async () => {
await User.deleteMany({});
});
// Disconnect after tests
afterAll(async () => {
await mongoose.connection.close();
});
describe('POST /api/users', () => {
test('should create a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'password123'
});
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('email', '[email protected]');
// Verify user was actually created in database
const user = await User.findOne({ email: '[email protected]' });
expect(user).toBeTruthy();
});
test('should return 409 if creating duplicate user', async () => {
// Create user first
await User.create({
email: '[email protected]',
password: 'password123'
});
// Try to create same user again
const response = await request(app)
.post('/api/users')
.send({
email: '[email protected]',
password: 'password123'
});
expect(response.status).toBe(409);
});
});
});
Setting Up a Testing Environment
To organize your tests effectively, consider this structure:
project/
├── src/
│ ├── controllers/
│ ├── models/
│ ├── routes/
│ └── utils/
├── tests/
│ ├── unit/
│ │ ├── controllers/
│ │ └── utils/
│ ├── integration/
│ │ └── routes/
│ └── e2e/
└── package.json
Add these scripts to your package.json
:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:unit": "jest tests/unit",
"test:integration": "jest tests/integration",
"test:e2e": "jest tests/e2e",
"test:coverage": "jest --coverage"
}
}
Test-Driven Development (TDD)
TDD is a development approach where you:
- Write a failing test
- Implement the minimum code to make the test pass
- Refactor the code while keeping the test passing
For Express applications, TDD can be particularly effective:
// Step 1: Write a failing test
test('GET /api/health should return status 200', async () => {
const response = await request(app).get('/api/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok' });
});
// Step 2: Implement the minimum code to make it pass
app.get('/api/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Step 3: Refactor if needed
const healthController = (req, res) => {
res.status(200).json({ status: 'ok' });
};
app.get('/api/health', healthController);
Best Practices for Express Testing
- Separate test concerns: Keep unit, integration, and E2E tests separate
- Mock external dependencies: Use Jest's mocking capabilities for external services
- Use environment variables: Configure different database URLs for test/development/production
- Test error handling: Don't just test the happy path; test error cases too
- Clean up after tests: Ensure your tests don't leave behind test data
- Use descriptive test names: Name your tests clearly to understand what failed
- Avoid testing Express internals: Focus on your application logic rather than Express itself
Real-World Example: Testing an Authentication System
Let's bring everything together with a more complex example of testing an authentication system:
// auth/authenticate.js
const jwt = require('jsonwebtoken');
const User = require('../models/user');
async function authenticate(req, res) {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
return res.json({ token, user: { id: user.id, email: user.email } });
} catch (error) {
return res.status(500).json({ error: 'Server error' });
}
}
module.exports = { authenticate };
Here's how to test it:
// tests/integration/auth.test.js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('../../app');
const User = require('../../models/user');
jest.mock('jsonwebtoken');
jest.mock('../../models/user');
describe('Authentication endpoints', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /api/login', () => {
test('should authenticate user and return token', async () => {
// Mock dependencies
const mockUser = {
id: '123',
email: '[email protected]',
comparePassword: jest.fn().mockResolvedValue(true)
};
User.findOne.mockResolvedValue(mockUser);
jwt.sign.mockReturnValue('mock-token');
// Test the endpoint
const response = await request(app)
.post('/api/login')
.send({
email: '[email protected]',
password: 'password123'
});
// Assertions
expect(response.status).toBe(200);
expect(response.body).toEqual({
token: 'mock-token',
user: {
id: '123',
email: '[email protected]'
}
});
// Verify mocks were called correctly
expect(User.findOne).toHaveBeenCalledWith({ email: '[email protected]' });
expect(mockUser.comparePassword).toHaveBeenCalledWith('password123');
expect(jwt.sign).toHaveBeenCalledWith(
{ userId: '123' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
});
test('should return 401 for invalid credentials', async () => {
// Mock a user that exists but password doesn't match
const mockUser = {
id: '123',
email: '[email protected]',
comparePassword: jest.fn().mockResolvedValue(false)
};
User.findOne.mockResolvedValue(mockUser);
// Test the endpoint
const response = await request(app)
.post('/api/login')
.send({
email: '[email protected]',
password: 'wrongpassword'
});
// Assertions
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
expect(jwt.sign).not.toHaveBeenCalled();
});
test('should return 401 for non-existent user', async () => {
// Mock user not found
User.findOne.mockResolvedValue(null);
// Test the endpoint
const response = await request(app)
.post('/api/login')
.send({
email: '[email protected]',
password: 'password123'
});
// Assertions
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
});
});
});
Summary
A robust testing strategy for your Express applications should include:
- Unit tests for individual functions and components
- Route handler tests for controller logic
- Integration tests for API endpoints
- End-to-end tests for complete user flows
Remember that tests are an investment in your application's quality and maintainability. While it takes time to write good tests, they pay dividends by catching bugs early, enabling confident refactoring, and serving as documentation for your code.
Additional Resources
- Jest Documentation
- SuperTest GitHub
- Express.js Testing Best Practices
- Test-Driven Development by Example by Kent Beck
- Testing JavaScript Applications by Lucas da Costa
Exercises
-
Write unit tests for a password validation function that checks:
- Minimum length of 8 characters
- At least one uppercase letter
- At least one number
- At least one special character
-
Create integration tests for a basic CRUD API that manages a collection of books
- Test creating, reading, updating, and deleting books
- Include validation tests
-
Implement a middleware that validates JWT tokens and write tests for:
- Valid tokens
- Invalid tokens
- Expired tokens
- Missing tokens
These exercises will help you practice different aspects of testing Express applications while building practical skills you can apply to your own projects.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)