Express Unit Testing
Introduction
Unit testing is a fundamental practice in software development that involves testing individual components or "units" of code to verify they work as expected. In Express applications, unit tests help ensure your routes, controllers, middleware, and other components function correctly in isolation.
Proper unit testing provides several benefits:
- Early bug detection: Find issues before they reach production
- Code confidence: Make changes with less fear of breaking existing functionality
- Documentation: Tests serve as examples of how code should behave
- Maintainability: Well-tested code is easier to modify and extend
In this guide, we'll explore how to set up and write effective unit tests for your Express applications using popular testing libraries like Mocha, Chai, and Supertest.
Setting Up Your Testing Environment
Before we write our first test, let's set up a testing environment for an Express application.
Required Tools
We'll use the following tools:
- Mocha: A feature-rich JavaScript test framework
- Chai: An assertion library that pairs well with Mocha
- Supertest: A library for testing HTTP requests
Installation
First, let's install these dependencies as development dependencies:
npm install --save-dev mocha chai supertest
Project Structure
A common way to organize tests is to create a test
directory at the root of your project:
my-express-app/
├── src/
│ ├── routes/
│ ├── controllers/
│ ├── models/
│ └── app.js
├── test/
│ ├── routes/
│ ├── controllers/
│ └── models/
├── package.json
└── README.md
Configuring package.json
Add a test script to your package.json
:
{
"scripts": {
"test": "mocha test/**/*.js --timeout 10000"
}
}
Writing Your First Unit Test
Let's create a simple Express route and write a test for it. First, the route:
// src/routes/userRoutes.js
const express = require('express');
const router = express.Router();
router.get('/users', (req, res) => {
res.status(200).json({
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
});
});
router.get('/users/:id', (req, res) => {
const userId = parseInt(req.params.id);
if (userId === 1) {
return res.status(200).json({ id: 1, name: 'Alice' });
}
if (userId === 2) {
return res.status(200).json({ id: 2, name: 'Bob' });
}
res.status(404).json({ error: 'User not found' });
});
module.exports = router;
Now, let's create an Express application that uses this route:
// src/app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const app = express();
app.use('/api', userRoutes);
module.exports = app;
Now let's write a test for these routes:
// test/routes/userRoutes.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../src/app');
describe('User Routes', () => {
describe('GET /api/users', () => {
it('should return all users', async () => {
const response = await request(app)
.get('/api/users')
.expect(200);
expect(response.body).to.be.an('object');
expect(response.body.users).to.be.an('array');
expect(response.body.users).to.have.lengthOf(2);
expect(response.body.users[0]).to.have.property('id', 1);
expect(response.body.users[0]).to.have.property('name', 'Alice');
});
});
describe('GET /api/users/:id', () => {
it('should return a specific user when valid ID is provided', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).to.be.an('object');
expect(response.body).to.have.property('id', 1);
expect(response.body).to.have.property('name', 'Alice');
});
it('should return 404 when user does not exist', async () => {
const response = await request(app)
.get('/api/users/999')
.expect(404);
expect(response.body).to.be.an('object');
expect(response.body).to.have.property('error', 'User not found');
});
});
});
Understanding the Test Structure
Let's break down what's happening in our test:
- We import
supertest
to make HTTP requests to our Express app - We import
expect
from Chai for assertions - We import our Express app module
- We structure our tests using Mocha's
describe
andit
functions - For each test case, we:
- Make an HTTP request to our app using Supertest
- Check the response status
- Verify the response body has the expected structure and data
The describe
function helps organize tests into logical groups, while the it
function defines individual test cases with descriptive names that explain what's being tested.
Testing Express Middleware
Middleware is a core concept in Express applications. Let's see how to test a custom middleware function:
First, let's create a simple authentication middleware:
// src/middleware/auth.js
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized: Missing or invalid token' });
}
const token = authHeader.split(' ')[1];
if (token !== 'valid-token') {
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
req.user = { id: 123, username: 'testuser' };
next();
}
module.exports = authMiddleware;
Now let's add a protected route to our app:
// src/routes/protectedRoutes.js
const express = require('express');
const authMiddleware = require('../middleware/auth');
const router = express.Router();
router.get('/protected', authMiddleware, (req, res) => {
res.status(200).json({
message: 'This is protected data',
user: req.user
});
});
module.exports = router;
Update our app to use this route:
// src/app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const protectedRoutes = require('./routes/protectedRoutes');
const app = express();
app.use('/api', userRoutes);
app.use('/api', protectedRoutes);
module.exports = app;
Now let's write tests for our middleware and protected route:
// test/routes/protectedRoutes.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../src/app');
describe('Protected Routes', () => {
describe('GET /api/protected', () => {
it('should return 401 when no token is provided', async () => {
const response = await request(app)
.get('/api/protected')
.expect(401);
expect(response.body).to.have.property('error');
expect(response.body.error).to.include('Unauthorized');
});
it('should return 401 when invalid token is provided', async () => {
const response = await request(app)
.get('/api/protected')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
expect(response.body).to.have.property('error');
expect(response.body.error).to.include('Unauthorized');
});
it('should return protected data when valid token is provided', async () => {
const response = await request(app)
.get('/api/protected')
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body).to.have.property('message', 'This is protected data');
expect(response.body).to.have.property('user');
expect(response.body.user).to.have.property('id', 123);
expect(response.body.user).to.have.property('username', 'testuser');
});
});
});
Testing with Mocks and Stubs
In real-world applications, your routes often interact with databases, external APIs, or other services. To properly unit test these components, you'll want to use mocks or stubs to isolate the code you're testing.
Let's create a simple user controller that interacts with a database:
// src/controllers/userController.js
const UserModel = require('../models/userModel');
class UserController {
async getAllUsers(req, res) {
try {
const users = await UserModel.findAll();
res.status(200).json({ users });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users' });
}
}
async getUserById(req, res) {
try {
const userId = parseInt(req.params.id);
const user = await UserModel.findById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user' });
}
}
}
module.exports = new UserController();
Now, let's install Sinon for creating mocks and stubs:
npm install --save-dev sinon
Let's write a test for our controller using Sinon to stub the UserModel:
// test/controllers/userController.test.js
const sinon = require('sinon');
const { expect } = require('chai');
const UserModel = require('../../src/models/userModel');
const userController = require('../../src/controllers/userController');
describe('User Controller', () => {
let req, res;
// Set up fresh req and res objects before each test
beforeEach(() => {
req = {
params: {}
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
});
// Clean up after each test
afterEach(() => {
sinon.restore();
});
describe('getAllUsers', () => {
it('should return all users', async () => {
// Arrange
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
sinon.stub(UserModel, 'findAll').resolves(mockUsers);
// Act
await userController.getAllUsers(req, res);
// Assert
expect(res.status.calledWith(200)).to.be.true;
expect(res.json.calledOnce).to.be.true;
expect(res.json.firstCall.args[0]).to.deep.equal({ users: mockUsers });
});
it('should handle errors', async () => {
// Arrange
sinon.stub(UserModel, 'findAll').rejects(new Error('Database error'));
// Act
await userController.getAllUsers(req, res);
// Assert
expect(res.status.calledWith(500)).to.be.true;
expect(res.json.calledWith({ error: 'Failed to fetch users' })).to.be.true;
});
});
describe('getUserById', () => {
it('should return a user when valid ID is provided', async () => {
// Arrange
const mockUser = { id: 1, name: 'Alice' };
req.params.id = '1';
sinon.stub(UserModel, 'findById').resolves(mockUser);
// Act
await userController.getUserById(req, res);
// Assert
expect(res.status.calledWith(200)).to.be.true;
expect(res.json.calledWith(mockUser)).to.be.true;
});
it('should return 404 when user not found', async () => {
// Arrange
req.params.id = '999';
sinon.stub(UserModel, 'findById').resolves(null);
// Act
await userController.getUserById(req, res);
// Assert
expect(res.status.calledWith(404)).to.be.true;
expect(res.json.calledWith({ error: 'User not found' })).to.be.true;
});
});
});
In this test file, we're using several key Sinon features:
- stubs - Replace functions with dummy versions we control
- spies - Observe function calls without changing behavior
- sinon.restore() - Clean up stubs and spies between tests
This approach allows us to test our controller logic in isolation without needing a real database connection.
Best Practices for Express Unit Testing
1. Structure Tests Logically
Use descriptive describe
and it
blocks to organize your tests in a way that makes them easy to understand.
2. Test One Thing at a Time
Each test case should verify one specific aspect of behavior. Avoid testing multiple things in a single test case.
3. Use Setup and Teardown
Use Mocha's before
, beforeEach
, after
, and afterEach
hooks to set up and clean up test environments.
4. Isolate External Dependencies
Use mocks and stubs to isolate the code you're testing from external dependencies like databases or APIs.
5. Test Error Handling
Don't just test the happy path. Make sure to test how your code handles errors and edge cases.
6. Keep Tests DRY
Extract common setup code into helper functions or hooks to avoid repetition.
7. Use Environment Variables
Use environment variables to configure your application differently in test mode.
Running Tests with Code Coverage
To see how much of your code is covered by tests, you can use a tool like NYC (Istanbul):
npm install --save-dev nyc
Update your package.json test script:
{
"scripts": {
"test": "mocha test/**/*.js --timeout 10000",
"coverage": "nyc npm test"
}
}
Run the coverage report:
npm run coverage
This will show you how much of your code is covered by tests and help identify areas that need more testing.
Summary
In this guide, we've explored how to set up and write effective unit tests for Express applications:
- Setting up a testing environment with Mocha, Chai, and Supertest
- Writing tests for Express routes and controllers
- Testing middleware functions
- Using mocks and stubs to isolate code under test
- Following best practices for Express unit testing
Unit testing is a critical part of building reliable Express applications. By writing comprehensive tests, you can ensure your code works correctly, catch bugs early, and make changes with confidence.
Additional Resources
- Mocha Documentation
- Chai Assertion Library
- Supertest for HTTP Testing
- Sinon.js for Stubs and Mocks
- Istanbul/NYC for Code Coverage
Practice Exercises
- Add unit tests for a route that creates a new user via POST request
- Write tests for error middleware that handles uncaught errors
- Test a route that requires query parameters
- Create tests for a controller that interacts with an external API
- Implement test coverage reporting and aim for at least 80% coverage
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)