Express API Testing
Introduction
Testing is a critical part of developing robust and maintainable APIs. When building Express REST APIs, proper testing ensures your endpoints behave as expected, handle errors gracefully, and continue to work as intended even as you make changes to your codebase.
In this tutorial, we'll explore how to implement effective testing strategies for your Express APIs. We'll cover both unit and integration testing approaches using popular JavaScript testing tools like Mocha, Chai, and Supertest.
Why Test Your Express APIs?
Before diving into the specifics, let's understand why testing your APIs is important:
- Catch bugs early: Detect issues before deploying to production
- Refactoring safety: Change your code with confidence
- Documentation: Tests serve as executable documentation
- API reliability: Ensure consistent behavior across all endpoints
- Regression prevention: Make sure new features don't break existing functionality
Setting Up Your Testing Environment
Let's start by setting up a testing environment for an Express API project.
Required Tools
First, install the necessary dependencies:
npm install --save-dev mocha chai supertest
Here's what each package does:
- Mocha: A flexible testing framework
- Chai: An assertion library that pairs with Mocha
- Supertest: A library for testing HTTP servers
Project Structure
A well-organized testing structure might look like:
project/
├── src/
│ ├── app.js
│ ├── routes/
│ │ └── userRoutes.js
│ └── controllers/
│ └── userController.js
├── test/
│ ├── unit/
│ │ └── userController.test.js
│ └── integration/
│ └── userRoutes.test.js
└── package.json
Basic Configuration
Add these scripts to your package.json
:
{
"scripts": {
"test": "mocha --recursive './test/**/*.test.js'",
"test:unit": "mocha --recursive './test/unit/**/*.test.js'",
"test:integration": "mocha --recursive './test/integration/**/*.test.js'"
}
}
Unit Testing Express Controllers
Unit tests focus on testing individual functions or components in isolation. Let's create a simple user controller and test it.
Example User Controller
First, let's create a user controller in src/controllers/userController.js
:
// src/controllers/userController.js
const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' }
];
exports.getUsers = (req, res) => {
return res.status(200).json(users);
};
exports.getUserById = (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
return res.status(200).json(user);
};
exports.createUser = (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ message: 'Name and email are required' });
}
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
return res.status(201).json(newUser);
};
Writing Unit Tests
Now, let's create a unit test for this controller in test/unit/userController.test.js
:
// test/unit/userController.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const userController = require('../../src/controllers/userController');
describe('User Controller', function() {
describe('getUsers', function() {
it('should return all users with status 200', function() {
// Arrange
const req = {};
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
// Act
userController.getUsers(req, res);
// Assert
expect(res.status.calledWith(200)).to.be.true;
expect(res.json.called).to.be.true;
expect(res.json.firstCall.args[0].length).to.be.at.least(2);
});
});
describe('getUserById', function() {
it('should return a user when valid ID is provided', function() {
// Arrange
const req = { params: { id: '1' } };
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
// Act
userController.getUserById(req, res);
// Assert
expect(res.status.calledWith(200)).to.be.true;
expect(res.json.calledWith(sinon.match({ id: 1, name: 'Alice' }))).to.be.true;
});
it('should return 404 when user is not found', function() {
// Arrange
const req = { params: { id: '999' } };
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
// Act
userController.getUserById(req, res);
// Assert
expect(res.status.calledWith(404)).to.be.true;
expect(res.json.calledWith(sinon.match({ message: 'User not found' }))).to.be.true;
});
});
});
For this example, we've used sinon
for mocking the request and response objects. You'll need to install it:
npm install --save-dev sinon
Integration Testing Express Routes
Integration tests verify that different parts of your application work together correctly. For Express APIs, this means testing the actual HTTP endpoints.
Example Express App
First, let's create our Express app with the user routes:
// src/app.js
const express = require('express');
const userController = require('./controllers/userController');
const app = express();
app.use(express.json());
// User routes
app.get('/api/users', userController.getUsers);
app.get('/api/users/:id', userController.getUserById);
app.post('/api/users', userController.createUser);
// For testing purposes, export the app without starting it
module.exports = app;
// When not being tested, start the server
if (require.main === module) {
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
Writing Integration Tests
Now, let's create integration tests for our routes in test/integration/userRoutes.test.js
:
// test/integration/userRoutes.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../src/app');
describe('User API Routes', function() {
describe('GET /api/users', function() {
it('should return all users', function(done) {
request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200)
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to.be.an('array');
expect(res.body.length).to.be.at.least(2);
done();
});
});
});
describe('GET /api/users/:id', function() {
it('should return a user when valid ID is provided', function(done) {
request(app)
.get('/api/users/1')
.expect('Content-Type', /json/)
.expect(200)
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to.be.an('object');
expect(res.body).to.have.property('id', 1);
expect(res.body).to.have.property('name', 'Alice');
done();
});
});
it('should return 404 when user is not found', function(done) {
request(app)
.get('/api/users/999')
.expect('Content-Type', /json/)
.expect(404)
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to.have.property('message', 'User not found');
done();
});
});
});
describe('POST /api/users', function() {
it('should create a new user with valid data', function(done) {
const newUser = {
name: 'Charlie',
email: '[email protected]'
};
request(app)
.post('/api/users')
.send(newUser)
.expect('Content-Type', /json/)
.expect(201)
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to.be.an('object');
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('name', 'Charlie');
expect(res.body).to.have.property('email', '[email protected]');
done();
});
});
it('should return 400 with invalid data', function(done) {
const invalidUser = {
name: 'Invalid User'
// Missing email
};
request(app)
.post('/api/users')
.send(invalidUser)
.expect('Content-Type', /json/)
.expect(400)
.end(function(err, res) {
if (err) return done(err);
expect(res.body).to.have.property('message', 'Name and email are required');
done();
});
});
});
});
Advanced Testing Techniques
As your API grows, you'll want to implement more advanced testing strategies.
Test Database Setup
For APIs connected to databases, it's good practice to use a test database:
// test/setup.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
// Set up the database before tests
before(async function() {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
// Clean up after tests
after(async function() {
await mongoose.disconnect();
await mongoServer.stop();
});
// Clear the database between tests
beforeEach(async function() {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});
You'll need to install:
npm install --save-dev mongodb-memory-server
Testing Authentication
Many APIs require authentication. Here's how to test protected routes:
const jwt = require('jsonwebtoken');
// Helper to generate a test token
function generateTestToken(userId = '123') {
return jwt.sign({ userId }, 'your_test_secret', { expiresIn: '1h' });
}
// Test a protected route
describe('Protected Routes', function() {
it('should access protected route with valid token', function(done) {
const token = generateTestToken();
request(app)
.get('/api/protected-resource')
.set('Authorization', `Bearer ${token}`)
.expect(200, done);
});
it('should reject request without token', function(done) {
request(app)
.get('/api/protected-resource')
.expect(401, done);
});
});
Using Test Hooks
Mocha provides hooks like before
, after
, beforeEach
, and afterEach
to set up and tear down test environments:
describe('User API with database', function() {
let testUser;
beforeEach(async function() {
// Create a test user before each test
testUser = await User.create({
name: 'Test User',
email: '[email protected]',
password: 'password123'
});
});
afterEach(async function() {
// Clean up after each test
await User.deleteMany({});
});
it('should return the user profile', function(done) {
request(app)
.get(`/api/users/${testUser._id}`)
.expect(200)
.end(function(err, res) {
if (err) return done(err);
expect(res.body.name).to.equal('Test User');
done();
});
});
});
Testing Best Practices
Here are some best practices to follow:
-
Test only one thing per test: Keep tests focused on a single functionality.
-
Use descriptive test names: The test name should explain what is being tested and what the expected outcome is.
-
Separate unit and integration tests: Run them independently when needed.
-
Mock external dependencies: Use tools like Sinon to mock APIs, databases, etc.
-
Set up CI/CD pipeline: Automate testing in your continuous integration workflow.
-
Test edge cases: Don't just test the happy path; consider invalid inputs, error scenarios, etc.
-
Maintain test coverage: Aim for at least 80% test coverage across your codebase.
Real-World Example: Testing a Todo API
Let's create a simple but complete example of testing a Todo API with a MongoDB database:
// src/models/Todo.js
const mongoose = require('mongoose');
const todoSchema = new mongoose.Schema({
title: { type: String, required: true },
completed: { type: Boolean, default: false },
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }
});
module.exports = mongoose.model('Todo', todoSchema);
// src/controllers/todoController.js
const Todo = require('../models/Todo');
exports.getAllTodos = async (req, res) => {
try {
const todos = await Todo.find({});
res.status(200).json(todos);
} catch (err) {
res.status(500).json({ message: 'Server error' });
}
};
exports.createTodo = async (req, res) => {
try {
const { title, userId } = req.body;
if (!title) {
return res.status(400).json({ message: 'Title is required' });
}
const todo = new Todo({ title, userId });
await todo.save();
res.status(201).json(todo);
} catch (err) {
res.status(500).json({ message: 'Server error' });
}
};
Here's how to test this API:
// test/integration/todoApi.test.js
const request = require('supertest');
const { expect } = require('chai');
const mongoose = require('mongoose');
const app = require('../../src/app');
const Todo = require('../../src/models/Todo');
describe('Todo API', function() {
before(async function() {
// Connect to a test database
await mongoose.connect(process.env.TEST_MONGO_URI || 'mongodb://localhost:27017/test_db');
});
after(async function() {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
beforeEach(async function() {
// Clean the todos collection before each test
await Todo.deleteMany({});
});
describe('GET /api/todos', function() {
it('should return all todos', async function() {
// Insert some test todos
await Todo.insertMany([
{ title: 'Test Todo 1', completed: false },
{ title: 'Test Todo 2', completed: true }
]);
const res = await request(app)
.get('/api/todos')
.expect('Content-Type', /json/)
.expect(200);
expect(res.body).to.be.an('array');
expect(res.body.length).to.equal(2);
expect(res.body[0]).to.have.property('title');
expect(res.body[0]).to.have.property('completed');
});
it('should return empty array when no todos exist', async function() {
const res = await request(app)
.get('/api/todos')
.expect('Content-Type', /json/)
.expect(200);
expect(res.body).to.be.an('array');
expect(res.body.length).to.equal(0);
});
});
describe('POST /api/todos', function() {
it('should create a new todo with valid data', async function() {
const newTodo = {
title: 'New Test Todo',
userId: new mongoose.Types.ObjectId().toString()
};
const res = await request(app)
.post('/api/todos')
.send(newTodo)
.expect('Content-Type', /json/)
.expect(201);
expect(res.body).to.be.an('object');
expect(res.body).to.have.property('_id');
expect(res.body).to.have.property('title', 'New Test Todo');
expect(res.body).to.have.property('completed', false);
// Verify it was actually saved to the database
const savedTodo = await Todo.findById(res.body._id);
expect(savedTodo).to.exist;
expect(savedTodo.title).to.equal('New Test Todo');
});
it('should return 400 when title is missing', async function() {
const invalidTodo = {
userId: new mongoose.Types.ObjectId().toString()
};
const res = await request(app)
.post('/api/todos')
.send(invalidTodo)
.expect('Content-Type', /json/)
.expect(400);
expect(res.body).to.have.property('message', 'Title is required');
// Verify nothing was saved
const count = await Todo.countDocuments();
expect(count).to.equal(0);
});
});
});
Summary
In this tutorial, we've covered how to effectively test Express REST APIs:
- Setting up the testing environment with Mocha, Chai, and Supertest
- Writing unit tests for controllers to verify individual function behavior
- Creating integration tests to verify API endpoints work as expected
- Advanced testing techniques including database testing and authentication testing
- Best practices to follow when testing your Express applications
Testing might seem like extra work initially, but it saves significant time and frustration in the long run. Well-tested APIs are more reliable, easier to maintain, and give you the confidence to refactor or extend your application.
Additional Resources
- Mocha Documentation
- Chai Assertion Library
- Supertest Documentation
- Sinon.js Documentation
- Test-Driven Development with Node.js
Exercises
- Add tests for a DELETE endpoint that removes a user by ID.
- Create tests for updating a user's information with a PUT request.
- Implement authentication middleware and write tests to verify it properly protects routes.
- Add validation middleware and write tests to ensure it correctly validates request bodies.
- Extend the Todo API example with tests for completing a todo and filtering todos by completion status.
By completing these exercises, you'll gain hands-on experience with testing Express APIs and be well-equipped to build reliable, production-ready applications.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)