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: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
]);

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 feedback@compilenrun.com. I’d greatly appreciate your feedback! :)