How to reach 100% unit test coverage with Node.js Express & Jest

Gilad Hoshmand
7 min readJan 21, 2023

Introduction

There is not going to be one, let’s go!

Assumptions

Trigger warning — there are going to be some assumptions.. I’m assuming you know:

  • Some JavaScript
  • Basic Node.js & Express setup
  • Familiar with Unit testing as a concept

I want to point out we are NOT going to focus on the 80% but rather on the 20% — those last few lines that are hard to cover — keeping in mind The Pareto Principle.
The 80% can be easily done by just applying regular unit testing methods,

Let’s GSD

We’ll have a simple made up Node.js + Express setup.
Our project structure is as follows:

.
└───src
└───controllers
│ abc.controller.ts
│ def.controller.ts
└───middlewares
│ auth.middleware.ts
│ postprocess.middleware.ts
└───services
│ db.service.ts
│ http.service.ts
│ app.ts
│ package.json
│ tsconfig.json
│ jest.config.ts

We’ll use jest so our scripts test scripts in package.json will include:

"scripts": {
"build": "tsc",
"test": "jest",
"coverage": "jest --coverage",
}

Now as-I-said I’m not going to explain unit testing, Node, Jest etc. — just give an example of the 80% part; let’s look at a simple implementation for the file with the terrible terrible name abc.controller.ts:

//abc.controller.ts
import * as service from '../services/db.service.js';
import { RequestHandler } from 'express';
const getById: RequestHandler = (req, res) => service.getById(
req.params.collection,
req.params.id
);
const getAll: RequestHandler = (req, res, next) => {
res.locals.result = await service.getAll(req.params.collection);
next();
};
export { getAll, getById };

The unit tests will look something like:

import * as controller from './abc.controller';
import { NextFunction, Request, Response } from 'express';
import * as service from '../services/db.service';
const testId = 'test-id';
const testCollection = 'test-entity-name';
jest.mock('../services/db.service', () => ({
__esModule: true,
getAll: jest.fn(),
getById: jest.fn(),
}));
afterAll(() => {
jest.clearAllMocks();
});
describe('Test controller', () => {
test('controller.getById calls service once with supplied collection & id', async () => {
jest.spyOn(service, 'getById');
await controller.getById(
{ params: { id: testId, collection: testCollection } } as unknown as Request,
{} as unknown as Response,
jest.fn() as NextFunction
);
expect(service.getById).toBeCalledTimes(1);
expect(service.getById).toBeCalledWith(testCollection, testId);
});
test('Test controller.getAll calls service once with supplied collection & calls the next middleware in chain', async () => {
jest.spyOn(service, 'getAll').mockImplementation();
const next: NextFunction = jest.fn();
await controller.getAll(
{ params: { collection: testCollection } } as unknown as Request,
{} as unknown as Response,
next
);
expect(service.getAll).toBeCalledTimes(1);
expect(service.getAll).toBeCalledWith(testCollection);
expect(next).toBeCalledTimes(1);
});
});

Now we’ve worked hard and managed to cover the 80% — controllers, services, middle-wares. Let’s see where we are at..

jest - coverage
Coverage results — looking good so far

Ahhh a lot of green! But wait! app.ts is RED!

The app.ts file is different then the others, it’s harder to test — it has settings, routing, controller & middle-ware flow logic.
For our first attempt we’ll simply import it because what is line coverage? It is a measure of lines executed and the app.ts file is mainly statements:

//app.ts
import express, { Express, Request, Response } from 'express';
import * as controller from './controllers/abc.controller.js';
import { authMiddleware } from './middlewares/auth.middleware.js';
import { postprocessMiddleware } from './middlewares/postprocess.middleware.js';
const app: Express = express();
const port = process.env.PORT;
app.get('/healthCheck', (req: Request, res: Response) => res.sendStatus(200));
app.get('/api/:collection', authMiddleware, controller.getAll, postMiddleware);
app.get('/api/:collection/:id', authMiddleware, controller.getById);
app.listen(port, () => console.log(`Node app listening on port ${port}`));
//app.test.ts
jest.mock('./controllers/abc.controller', () => ({
__esModule: true,
getAll: jest.fn(),
getById: jest.fn(),
}));
jest.mock('./middlewares/auth.middleware', () => ({
__esModule: true,
authMiddleware: jest.fn(),
}));
jest.mock('./middlewares/postprocess.middleware', () => ({
__esModule: true,
postprocessMiddleware: jest.fn(),
}));
jest.mock('express', () => ({
default: () => ({ get: jest.fn(), listen: jest.fn() }),
}));
import './app'; //importing will execute most of the code!
describe('Test App code coverage by import', function () {
it('Should be a truthy test because the test suite cannot be empty', () => {
expect(1).toBe(1);
});
});

Let’s see the results again:

Now we did get to 100% line coverage but if you’re OCD about gamified stuff like me you HAVE TO GET TO 100% no matter what it takes.

The Problem

This looks like a non-issue, some of you may say it is a non issue but if the app grows this could get painful. Let’s see what we can do..
The main problem is that we have some high coupling in this file and we need to make many mocks, some of them even have some logic baked into them. The problem is grown in magnitude as the app requires more configurations maybe with some different settings for dev & prod.
This could be very hard to hard to maintain over time.

Another issue is that some inner (non-exported) functions in the module cannot be imported and tested on their own — which again means we are coupling things together and not getting reliable feedback while also making it harder to introduce isolated changes without breaking tests (that are required to test too many things at once).

To cover everything we need to execute the code — but we’re just unit testing the app module, what if these executions cause side effects? what if some data is inserted into our production db?

To demonstrate we will look at the /healthcheck endpoint handler & the callback executed after app.listen is called.

The solution — The Good, the Bad, the Ugly & the Quick and Dirty

The Ugly

This is really messy and I’m all against writing more code just to make some test work. Just imagine how these magic lines & hacks could grow over time as more functionality is needed in our app.

//app.ts
const healthCheckHandler: RequestHandler = (req, res) => res.sendStatus(200); // extracted function that is missing coverage
// app configuration...
app.get('/healthCheck', healthCheckHandler);
let exportForTest;
if (process.env.NODE_ENV == 'test') {
exportForTest = healthCheckHandler; // set exported function only in test env
}
export { exportForTest }; // must be at top level ( cannot happen inside the 'if' - that is why we need an extra var exportForTest)

This is a short term fix; the technique is hacky and the growth will be painful.

Everyone will hate you.

The Bad

You can simply decide that the coverage for this file is purely cosmetic and exclude it in the jest.config.ts file:

const config: Config.InitialOptions = {
// ...
coveragePathIgnorePatterns: ['./src/app.ts'],
};

Or exclude the specific lines from coverage using some predetermined comment — check out the docs.

We’re not even going to discuss these I’m disgusted with you for even thinking about it.

Quick & (a little) Dirty

Changing the mocks to implement some of the logic will get us some coverage (in this case for express.listen line).

// app.test.ts
jest.mock('express', () => ({
default: () => ({ get: jest.fn(), listen: (port:number, callback:() => void ) => callback() }),
}));

We still have the cover the /healthcheck endpoint. We can do that with an external package like supertest. This will not be covered here but basically mimics a call to an endpoint.

This is a short term win — we actually got to 100%.
But it’s also now obvious that as the app will grow so will the complexity of test logic and scenarios to cover — ie. what if we have some complex logic for the callback? Will we rewrite that too?

The Good

Looking closer at app.ts we start to understand that if we had separation between the entry point, app and routing some of the testing will be easy and natural — no hacks no tricks.

// modified app.ts
import express, { Express } from 'express';
import router from './router.js';
// In the future some other imports for app tools we need, example:
// import myFavoriteLogger from 'logger-package-name';
// app.use(myFavoriteLogger) <-- after app init
const app: Express = express();
app.use('/', router);
export default app;
// new file router.ts - defines the routes and middleware/handler chain
import { Request, Response, Router } from 'express';
import { authMiddleware } from './middlewares/auth.middleware.js';
import * as controller from './controllers/abc.controller.js';
import { postprocessMiddleware } from './middlewares/postprocess.middleware.js';
const router = Router();
router.get('/healthCheck', (req: Request, res: Response) => res.sendStatus(200));
router.get('/api/:collection/:id', authMiddleware, controller.getById,);
router.get('/api/:collection', authMiddleware, controller.getAll, postprocessMiddleware);
export default router;
// new file index.ts - this will be the entrypoint for the application
import * as dotenv from 'dotenv';
import app from './app.js';

dotenv.config();
const port = process.env.PORT;

app.listen(port, () => console.log(`Node app listening on port ${port}`));

Look at the result — it’s so easy to understand where you control entry point load orchestration, adding the next endpoint, modifying (Express) app related settings.
The aftermath is 3 files instead of 1 and 3 test files instead of 1.
Let’s check out one of them:

// app.test.ts
jest.mock('express', () => ({
default: () => ({ use: jest.fn(), options: jest.fn() }),
}));

import './app';
afterAll(() => {
jest.clearAllMocks();
});
describe('Test app.ts code', function () {
it('Everything is ran by importing app module, this is here so that the suite is not empty', () => {
expect(1).toBe(1);
});
});

Que clean! Que beautiful! This gives 100% because all lines were executed!

Refactoring the entry point to index, app & router modules had given us a clearer view of what isn’t covered and removed some mocking issues.
We can now combine some (the least possible) mocking with importing to easily gain 100% coverage. Any specific line/function can be handled in its own module without importing the whole app (ie. router stuff will be tested alone). So we might still decide to make mocked logic and use some package to mimic calls but our modules are de-coupled making it easier to maintain & much more predictable to test.

Explain it like I’m 9

Always put toys in different appropriate boxes, it is sometimes annoying but we can find/change/rely on/use them better tomorrow.

Go clean your room now.

Note/Disclaimer

100% code coverage does not guarantee no bugs or correctness.
For more reading on that check out:
This answer (not by me) on SO.
This read has some good coin dropping moments, one of them is very relevant to this disclaimer:
Test Coverage = (Executed Test cases/Total Test cases) * 100
Code Coverage = (number of lines of code executed/ total number of lines of code)* 100

--

--