Express Integration Testing
Introduction
While unit tests verify individual components of your Express.js application in isolation, integration testing takes your testing strategy to the next level by examining how multiple components work together. In an Express application, integration tests typically verify that:
- API endpoints respond correctly to different requests
- Middleware functions process requests properly
- Routes correctly interact with your database
- Authentication and authorization systems work as expected
- Error handling works correctly across components
Integration tests provide confidence that your application's various parts collaborate correctly, catching issues that might not be visible when testing components in isolation.
Prerequisites
Before diving into integration testing for Express applications, you should have:
- Basic understanding of Express.js
- Familiarity with JavaScript testing concepts
- An Express application that you want to test
Testing Tools for Express Integration Tests
For integration testing Express applications, we'll use the following popular tools:
- Mocha: A feature-rich JavaScript test framework
- Chai: An assertion library that pairs well with Mocha
- Supertest: A library specifically designed for testing HTTP servers
Let's set up our testing environment:
npm install --save-dev mocha chai supertest
Basic Integration Test Structure
Here's a simple structure for an Express integration test:
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app'); // Import your Express application
describe('User API Endpoints', () => {
it('should return all users', async () => {
const res = await request(app)
.get('/api/users')
.expect(200);
expect(res.body).to.be.an('array');
expect(res.body.length).to.be.greaterThan(0);
});
it('should create a new user', async () => {
const newUser = {
name: 'Test User',
email: '[email protected]',
password: 'password123'
};
const res = await request(app)
.post('/api/users')
.send(newUser)
.expect(201);
expect(res.body).to.have.property('name', newUser.name);
expect(res.body).to.have.property('email', newUser.email);
});
});
Setting Up a Test Database
For proper integration testing, you should use a separate test database to avoid interfering with development or production data.
Example with MongoDB:
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
// Setup test database before tests
before(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
// Clean up database between tests
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
// Disconnect and stop server after tests
after(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
Testing Common Express Components
1. Testing Routes and Controllers
Let's test a complete route that interacts with a database:
// test/integration/product.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
const Product = require('../../models/Product');
describe('Product API', () => {
beforeEach(async () => {
// Seed test data
await Product.create([
{ name: 'Laptop', price: 999, inStock: true },
{ name: 'Phone', price: 699, inStock: true },
{ name: 'Headphones', price: 99, inStock: false }
]);
});
describe('GET /api/products', () => {
it('should return all products', async () => {
const res = await request(app)
.get('/api/products')
.expect(200);
expect(res.body).to.be.an('array');
expect(res.body.length).to.equal(3);
});
it('should filter products by inStock status', async () => {
const res = await request(app)
.get('/api/products?inStock=true')
.expect(200);
expect(res.body).to.be.an('array');
expect(res.body.length).to.equal(2);
res.body.forEach(product => {
expect(product.inStock).to.be.true;
});
});
});
});
2. Testing Middleware
Middleware functions are critical parts of Express applications, and we should verify their behavior in integration tests:
// test/integration/auth-middleware.test.js
const request = require('supertest');
const { expect } = require('chai');
const express = require('express');
const jwt = require('jsonwebtoken');
// Create a test app with the auth middleware
const authMiddleware = require('../../middleware/auth');
const app = express();
// Protected route using auth middleware
app.get('/protected', authMiddleware, (req, res) => {
res.status(200).json({ message: 'Access granted', user: req.user });
});
describe('Auth Middleware', () => {
it('should return 401 if no token is provided', async () => {
const res = await request(app)
.get('/protected')
.expect(401);
expect(res.body).to.have.property('message', 'No token, authorization denied');
});
it('should return 401 for invalid token', async () => {
const res = await request(app)
.get('/protected')
.set('x-auth-token', 'invalid-token')
.expect(401);
expect(res.body).to.have.property('message', 'Token is not valid');
});
it('should allow access with valid token', async () => {
// Create a valid token for testing
const payload = { user: { id: '123', role: 'admin' } };
const token = jwt.sign(payload, process.env.JWT_SECRET || 'test-secret', { expiresIn: '1h' });
const res = await request(app)
.get('/protected')
.set('x-auth-token', token)
.expect(200);
expect(res.body).to.have.property('message', 'Access granted');
expect(res.body.user).to.deep.equal(payload.user);
});
});
3. Testing Error Handling
Proper error handling is crucial for robust applications. Let's test error scenarios:
// test/integration/error-handling.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
describe('Error Handling', () => {
it('should return 404 for non-existing routes', async () => {
const res = await request(app)
.get('/api/non-existing-route')
.expect(404);
expect(res.body).to.have.property('message').that.includes('not found');
});
it('should return 400 for invalid input', async () => {
// Assuming we have a route that validates input
const invalidInput = {
// Missing required fields
};
const res = await request(app)
.post('/api/users')
.send(invalidInput)
.expect(400);
expect(res.body).to.have.property('errors');
});
it('should handle server errors with 500 status', async () => {
// Assuming we have a route that can trigger a server error
// For example, by passing an ID that causes a database error
const res = await request(app)
.get('/api/cause-error')
.expect(500);
expect(res.body).to.have.property('message').that.includes('Server Error');
});
});
Testing Authentication Flows
Authentication is a critical component of many applications. Let's test a complete authentication flow:
// test/integration/auth.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
const User = require('../../models/User');
const bcrypt = require('bcryptjs');
describe('Authentication Flow', () => {
before(async () => {
// Create a test user
const passwordHash = await bcrypt.hash('testPassword123', 10);
await User.create({
name: 'Test User',
email: '[email protected]',
password: passwordHash
});
});
describe('POST /api/auth/login', () => {
it('should login successful with valid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'testPassword123'
})
.expect(200);
expect(res.body).to.have.property('token');
expect(res.body).to.have.property('user');
expect(res.body.user).to.have.property('email', '[email protected]');
});
it('should reject login with invalid password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'wrongPassword'
})
.expect(401);
expect(res.body).to.have.property('message', 'Invalid credentials');
});
it('should reject login for non-existent user', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'testPassword123'
})
.expect(401);
expect(res.body).to.have.property('message', 'Invalid credentials');
});
});
});
Testing Database Interactions
Integration tests should verify that your API correctly interacts with your database:
// test/integration/todo-crud.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
const Todo = require('../../models/Todo');
const mongoose = require('mongoose');
describe('Todo CRUD Operations', () => {
let authToken;
let userId;
before(async () => {
// Create a test user and get authentication token
const registerRes = await request(app)
.post('/api/auth/register')
.send({
name: 'Todo Tester',
email: '[email protected]',
password: 'password123'
});
authToken = registerRes.body.token;
userId = registerRes.body.user.id;
});
describe('Todo Operations', () => {
let todoId;
it('should create a new todo', async () => {
const newTodo = {
title: 'Test Todo',
description: 'This is a test todo',
priority: 'high'
};
const res = await request(app)
.post('/api/todos')
.set('x-auth-token', authToken)
.send(newTodo)
.expect(201);
expect(res.body).to.have.property('title', newTodo.title);
expect(res.body).to.have.property('user', userId);
expect(res.body).to.have.property('completed', false);
// Save todo ID for later tests
todoId = res.body._id;
// Verify todo was saved to database
const savedTodo = await Todo.findById(todoId);
expect(savedTodo).to.not.be.null;
expect(savedTodo.title).to.equal(newTodo.title);
});
it('should retrieve the created todo', async () => {
const res = await request(app)
.get(`/api/todos/${todoId}`)
.set('x-auth-token', authToken)
.expect(200);
expect(res.body).to.have.property('_id', todoId);
expect(res.body).to.have.property('title', 'Test Todo');
});
it('should update the todo', async () => {
const updates = {
title: 'Updated Todo',
completed: true
};
const res = await request(app)
.put(`/api/todos/${todoId}`)
.set('x-auth-token', authToken)
.send(updates)
.expect(200);
expect(res.body).to.have.property('title', updates.title);
expect(res.body).to.have.property('completed', true);
// Verify database was updated
const updatedTodo = await Todo.findById(todoId);
expect(updatedTodo.title).to.equal(updates.title);
expect(updatedTodo.completed).to.be.true;
});
it('should delete the todo', async () => {
await request(app)
.delete(`/api/todos/${todoId}`)
.set('x-auth-token', authToken)
.expect(200);
// Verify todo was removed from database
const deletedTodo = await Todo.findById(todoId);
expect(deletedTodo).to.be.null;
});
});
});
Best Practices for Express Integration Testing
-
Use a dedicated test database: Never test against your production or development database.
-
Clean up after tests: Reset your test database between test runs to prevent test interdependence.
-
Test the happy path and error cases: Don't just test when everything works correctly; test error handling too.
-
Mock external services: If your app calls external APIs, mock these calls to avoid network dependencies.
-
Test middleware in context: Don't just test middleware functions in isolation; test them in the context of a route.
-
Use proper assertions: Be specific about what you expect from responses.
-
Run tests in isolation: Each test should work independently of others.
-
Organize tests logically: Group related tests together using describe blocks.
Common Integration Testing Challenges
1. Managing Test Data
Managing test data can be challenging. Consider using fixtures or factory functions:
// test/fixtures/users.js
const bcrypt = require('bcryptjs');
const createTestUser = async (User, override = {}) => {
const defaultUser = {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
password: await bcrypt.hash('password123', 10)
};
return User.create({ ...defaultUser, ...override });
};
module.exports = { createTestUser };
2. Testing File Uploads
Testing file uploads requires simulating multipart/form-data requests:
const path = require('path');
it('should upload a profile photo', async () => {
const res = await request(app)
.post('/api/users/profile-photo')
.set('x-auth-token', authToken)
.attach('photo', path.join(__dirname, '../fixtures/test-image.jpg'))
.expect(200);
expect(res.body).to.have.property('photoUrl');
expect(res.body.photoUrl).to.include('uploads/');
});
3. Testing WebSockets
For WebSocket testing, you can use libraries like socket.io-client
:
const io = require('socket.io-client');
const { createServer } = require('http');
const { Server } = require('socket.io');
describe('WebSocket Chat', () => {
let httpServer;
let ioServer;
let clientSocket;
before((done) => {
httpServer = createServer();
ioServer = new Server(httpServer);
httpServer.listen(() => {
const port = httpServer.address().port;
clientSocket = io(`http://localhost:${port}`);
clientSocket.on('connect', done);
});
// Setup your socket handlers here
require('../../socket/chat')(ioServer);
});
after(() => {
if (clientSocket) clientSocket.disconnect();
if (ioServer) ioServer.close();
if (httpServer) httpServer.close();
});
it('should send a message to all clients', (done) => {
// Create a second client to receive messages
const port = httpServer.address().port;
const clientSocket2 = io(`http://localhost:${port}`);
clientSocket2.on('chat message', (msg) => {
expect(msg).to.equal('Hello world');
clientSocket2.disconnect();
done();
});
clientSocket.emit('chat message', 'Hello world');
});
});
Summary
Integration testing is an essential part of building reliable Express.js applications. By testing how your routes, middleware, controllers, and database models work together, you can catch bugs that might be missed by unit tests alone.
In this guide, we've explored:
- Setting up an integration testing environment with Mocha, Chai, and Supertest
- Testing API endpoints, middleware, and error handling
- Verifying authentication flows and database interactions
- Handling common integration testing challenges
By implementing comprehensive integration tests, you'll build more reliable Express applications that deliver consistent, expected behavior across all components.
Additional Resources
- Supertest Documentation
- Mocha Documentation
- Chai Documentation
- MongoDB Memory Server for testing with MongoDB
- Testing Express.js Applications with Supertest
Exercises
- Create integration tests for a simple to-do API with CRUD operations
- Implement authentication tests for a user login/registration system
- Write tests for an API that includes file uploads
- Build tests for a route that requires multiple middleware functions
- Create tests for error handling and edge cases in an Express application
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)