Express Test Coverage
Introduction to Test Coverage
Test coverage is a metric that helps you understand how much of your code is being tested by your test suite. In Express applications, test coverage is crucial as it ensures that critical parts of your server-side code are properly tested before deployment.
Having high test coverage doesn't guarantee bug-free code, but it does significantly reduce the risk of undetected bugs and regressions. It provides confidence that changes to your codebase won't break existing functionality.
Why Test Coverage Matters
For Express.js applications, test coverage is particularly important because:
- Server reliability - Express powers backend services that many users and systems depend on
- API contract integrity - Ensures your endpoints behave as documented
- Regression prevention - Helps catch issues when modifying existing code
- Documentation - Good tests serve as documentation of how your code should work
Setting Up Test Coverage Tools
The most popular test coverage tool for Express applications is Istanbul (nyc), often used together with Jest, Mocha, or other testing frameworks.
Installing Dependencies
Let's start by installing the necessary tools:
# If using Jest
npm install --save-dev jest supertest
# If using Mocha with Istanbul
npm install --save-dev mocha chai supertest nyc
Configuring Coverage in package.json
Add the following to your package.json
:
{
"scripts": {
"test": "jest --coverage",
// Or with Mocha + Istanbul
"test-mocha": "nyc --reporter=html --reporter=text mocha --recursive"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.js",
"!**/node_modules/**"
],
"coverageThreshold": {
"global": {
"statements": 80,
"branches": 80,
"functions": 80,
"lines": 80
}
}
}
}
Writing Tests for Express Routes
Let's see a complete example of testing Express routes with coverage:
Sample Express Application
First, our simple Express app (app.js
):
const express = require('express');
const app = express();
app.use(express.json());
// User routes
app.get('/api/users', (req, res) => {
// Normally would fetch from database
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
});
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
// Simple validation
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid ID' });
}
// Normally would fetch from database
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// Export for testing
module.exports = app;
Test File with Coverage
Now, let's create a test file (app.test.js
):
const request = require('supertest');
const app = require('./app');
describe('User API Endpoints', () => {
// Test GET /api/users
describe('GET /api/users', () => {
it('should return all users', async () => {
const res = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(res.body).toHaveLength(2);
expect(res.body[0]).toHaveProperty('id');
expect(res.body[0]).toHaveProperty('name');
});
});
// Test GET /api/users/:id
describe('GET /api/users/:id', () => {
it('should return a user when valid ID provided', async () => {
const res = await request(app)
.get('/api/users/1')
.expect('Content-Type', /json/)
.expect(200);
expect(res.body).toHaveProperty('id', 1);
expect(res.body).toHaveProperty('name', 'Alice');
});
it('should return 404 when user not found', async () => {
const res = await request(app)
.get('/api/users/999')
.expect('Content-Type', /json/)
.expect(404);
expect(res.body).toHaveProperty('error', 'User not found');
});
it('should return 400 when ID is invalid', async () => {
const res = await request(app)
.get('/api/users/abc')
.expect('Content-Type', /json/)
.expect(400);
expect(res.body).toHaveProperty('error', 'Invalid ID');
});
});
});
Understanding Coverage Reports
When you run your tests with coverage enabled, you'll get a report showing:
- Statement coverage - percentage of statements that have been executed
- Branch coverage - percentage of branches (if/else, switch cases) that have been executed
- Function coverage - percentage of functions that have been called
- Line coverage - percentage of lines of code that have been executed
Here's an example of what a coverage report might look like:
----------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------|---------|----------|---------|---------|
All files | 95.24 | 83.33 | 100 | 95.24 |
app.js | 95.24 | 83.33 | 100 | 95.24 |
----------------|---------|----------|---------|---------|
In this example, we have:
- 95.24% statement coverage
- 83.33% branch coverage
- 100% function coverage
- 95.24% line coverage
Improving Test Coverage
Let's say our coverage report shows we're missing some branches. How do we improve that?
Identifying Gaps
Istanbul generates an HTML report that shows exactly which lines and branches aren't covered:
# Go to coverage/lcov-report/index.html after running tests
open coverage/lcov-report/index.html
Adding Tests for Missing Coverage
Let's add a test for a previously untested edge case:
it('should handle empty request body on POST', async () => {
const res = await request(app)
.post('/api/users')
.send({}) // Empty request body
.expect('Content-Type', /json/)
.expect(400);
expect(res.body).toHaveProperty('error', 'Name is required');
});
Practical Coverage Strategies
1. Focus on Critical Paths First
For Express applications, prioritize coverage for:
- Route handlers
- Middleware functions
- Authentication logic
- Data validation
2. Test Error Handling
Make sure you test error conditions:
describe('Error handling', () => {
it('should handle database connection errors', async () => {
// Mock database failure
mockDB.connect = jest.fn().mockRejectedValue(new Error('DB Error'));
const res = await request(app)
.get('/api/users')
.expect(500);
expect(res.body).toHaveProperty('error');
});
});
3. Test Middleware
Middleware is a crucial part of Express apps that needs testing:
const authMiddleware = require('./authMiddleware');
describe('Auth Middleware', () => {
it('should proceed when valid token provided', () => {
const req = { headers: { authorization: 'Bearer valid_token' } };
const res = {};
const next = jest.fn();
// Mock token verification
jwt.verify = jest.fn().mockReturnValue({ userId: 123 });
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user).toEqual({ userId: 123 });
});
it('should return 401 when no token provided', () => {
const req = { headers: {} };
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
const next = jest.fn();
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
});
Setting Coverage Thresholds
It's good practice to set minimum coverage thresholds to maintain code quality:
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
With this configuration, if coverage drops below these thresholds, the tests will fail, preventing code with insufficient test coverage from being merged or deployed.
Integrating Coverage into CI/CD
You can add coverage checks to your CI/CD pipeline:
# Example GitHub Actions workflow
name: Node.js CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
- run: npm ci
- run: npm test
# Optional: upload coverage as artifact
- name: Upload coverage report
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: coverage/
Real-world Express Test Coverage Example
Let's look at a more complete example including a controller, model, and routes:
Project Structure
src/
controllers/
userController.js
models/
userModel.js
routes/
userRoutes.js
app.js
test/
userController.test.js
userRoutes.test.js
Testing User Routes
// userRoutes.test.js
const request = require('supertest');
const app = require('../src/app');
const userModel = require('../src/models/userModel');
// Mock the user model
jest.mock('../src/models/userModel');
describe('User Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/users', () => {
it('should fetch all users', async () => {
// Setup mock response
userModel.getAllUsers.mockResolvedValue([
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' }
]);
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(2);
expect(userModel.getAllUsers).toHaveBeenCalledTimes(1);
});
it('should handle errors when fetching users fails', async () => {
// Setup mock to throw error
userModel.getAllUsers.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(500);
expect(response.body).toHaveProperty('error');
});
});
});
Summary
Test coverage is an essential aspect of building reliable Express applications. It provides confidence in your code, helps identify untested logic, and serves as a quality check for your development process.
Key takeaways:
- Set up coverage tools (Istanbul/nyc) with your testing framework
- Focus on covering critical paths, error handling, and edge cases
- Understand coverage reports and identify gaps
- Set coverage thresholds to maintain quality standards
- Integrate coverage checks into your CI/CD pipeline
Remember that 100% code coverage is rarely necessary or practical. Instead, aim for high coverage of critical paths and business logic, and make thoughtful decisions about which parts of your code require thorough testing.
Additional Resources
- Istanbul Documentation
- Jest Coverage Configuration
- Supertest for Express Testing
- Express Testing Best Practices
Exercises
- Set up test coverage for an existing Express project
- Identify uncovered code paths and write tests for them
- Configure coverage thresholds appropriate for your project
- Create a CI pipeline that enforces coverage requirements
- Practice writing tests for different HTTP methods (GET, POST, PUT, DELETE) and ensure high coverage
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)