Skip to main content

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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

bash
npm install --save-dev supertest

Example: Testing API Endpoints

Let's test a complete route:

javascript
// 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:

javascript
// 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:

javascript
// 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:

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:

  1. Write a failing test
  2. Implement the minimum code to make the test pass
  3. Refactor the code while keeping the test passing

For Express applications, TDD can be particularly effective:

javascript
// 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

  1. Separate test concerns: Keep unit, integration, and E2E tests separate
  2. Mock external dependencies: Use Jest's mocking capabilities for external services
  3. Use environment variables: Configure different database URLs for test/development/production
  4. Test error handling: Don't just test the happy path; test error cases too
  5. Clean up after tests: Ensure your tests don't leave behind test data
  6. Use descriptive test names: Name your tests clearly to understand what failed
  7. 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:

javascript
// 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:

javascript
// 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:

  1. Unit tests for individual functions and components
  2. Route handler tests for controller logic
  3. Integration tests for API endpoints
  4. 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

Exercises

  1. 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
  2. Create integration tests for a basic CRUD API that manages a collection of books

    • Test creating, reading, updating, and deleting books
    • Include validation tests
  3. 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! :)