Skip to main content

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:

  1. Server reliability - Express powers backend services that many users and systems depend on
  2. API contract integrity - Ensures your endpoints behave as documented
  3. Regression prevention - Helps catch issues when modifying existing code
  4. 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:

bash
# 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:

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):

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

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

  1. Statement coverage - percentage of statements that have been executed
  2. Branch coverage - percentage of branches (if/else, switch cases) that have been executed
  3. Function coverage - percentage of functions that have been called
  4. 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:

bash
# 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:

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

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

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

json
"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:

yaml
# 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

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

  1. Set up coverage tools (Istanbul/nyc) with your testing framework
  2. Focus on covering critical paths, error handling, and edge cases
  3. Understand coverage reports and identify gaps
  4. Set coverage thresholds to maintain quality standards
  5. 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

Exercises

  1. Set up test coverage for an existing Express project
  2. Identify uncovered code paths and write tests for them
  3. Configure coverage thresholds appropriate for your project
  4. Create a CI pipeline that enforces coverage requirements
  5. 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! :)