NestJS - Unit and E2E testing
Rohith Poyyeri
Posted on March 8, 2023
Testing is a fundamental aspect of development that helps build trust in the code and ensures it can handle various scenarios. It also serves as an early warning system when changes are made to the code. If a test fails, it indicates that an unintended change has occurred, which either requires fixing the code or updating the test.
In this post, we take a look at implementing testing in a NestJS application which was set up previously in this post - NestJS – Supercharging Node.js Applications. Now that we have a basic understanding of NestJS, this post will explore how to test the API.
Test Types
There are three main recommendations for testing:
Unit testing
End-to-end (E2E) testing
SuperTest
Unit testing
It is best for a JavaScript function to be kept short, ideally with no more than three lines of code. However, this can be difficult to adhere to. Unit testing involves testing a function on its own, to ensure it is isolated and functioning properly. This allows for thorough testing of each part of the code, independently, and without interference from other functions or services. It is highly recommended to have as many tests as possible, as there is no such thing as too many tests.
E2E testing
As mentioned in my first blog, NestJS has a designated entry point module. In the example provided, the TodoModule was the entry point, responsible for setting up routes, injecting controllers, and services. End-to-end (E2E) testing can be used to verify the functionality of this module. The test bed injects the TodoModule and sets up routes, controllers, and services. By testing the module end-to-end, we can ensure that it works as intended throughout the entire process.
Supertest
We need a solution that can test the API regardless of the framework it is built on. The API can be built using NodeJS, NestJS, or any other framework. Supertest provides a framework-agnostic testing suite that allows for end-to-end testing of the API.
Configuration
To configure the tests, let us start by installing some dependencies.
$ yarn add supertest jest-sonar jest-junit @jest-performance-reporter/core --save-dev
Now, let’s set up an initial base configuration.
jest.config.js
This is the base configuration for all the tests.
module.exports = {
testEnvironment: 'node',
preset: 'ts-jest',
rootDir: './',
modulePaths: ['<rootDir>'],
moduleNameMapper: {
'^src$': '<rootDir>/src',
'^src/(.+)$': '<rootDir>/src/$1',
},
modulePathIgnorePatterns: ['src/typings'],
testPathIgnorePatterns: [
'/node_modules./',
'<rootDir>/(coverage|dist|lib|tmp)./',
],
};
jest.unit.js
This is the configuration for unit tests, which inherits the base configuration. The coverage has been set to low for the time being. Ideally, this has to be above 80.
const sharedConfig = require('./jest.config');
module.exports = {
...sharedConfig,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'cobertura'],
collectCoverageFrom: [
'src/**/*.ts',
'!*/node_modules/**',
'!<rootDir>/src/main.ts',
'!<rootDir>/src/modules/database/database.service.ts',
],
reporters: [
'default',
'jest-sonar',
[
'jest-junit',
{
outputDirectory: 'junit',
outputName: 'test-results.xml',
},
],
[
'@jest-performance-reporter/core',
{
errorAfterMs: 1000,
warnAfterMs: 500,
logLevel: 'warn',
maxItems: 5,
jsonReportPath: 'performance-report.json',
csvReportPath: 'performance-report.csv',
},
],
],
coverageThreshold: {
global: {
branches: 10,
functions: 10,
lines: 10,
statements: 10,
},
},
};
jest.e2e.js
This is the configuration for E2E tests, which inherits the base configuration.
const sharedConfig = require('./jest.config');
module.exports = {
...sharedConfig,
moduleFileExtensions: ['js', 'json', 'ts'],
testRegex: '.e2e-spec.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
};
jest.supertest.js
This is the configuration for supertest tests, which inherits the base configuration.
const sharedConfig = require('./jest.config');
module.exports = {
...sharedConfig,
moduleFileExtensions: ['js', 'json', 'ts'],
testRegex: '.supertest-spec.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
};
Now that we have all the configurations in place, we need to update our scripts in package.json. All/update the following scripts.
"test": "jest --config ./jest.unit.js",
"test:cov": "yarn test --coverage",
"test:e2e": "jest --config ./jest.e2e.js",
"test:supertest": "jest --config ./jest.supertest.js"
Testing
Now let’s look at our previous sample code repo and get our hands dirty with some testing. Before we start, let us mock the database service. We will look into implementing MongoDB as a separate blog.
import { Injectable } from '@nestjs/common';
import { ToDo } from 'src/models/ToDo';
@Injectable()
export class DataBaseService {
data: ToDo[] = [
{
id: '1',
description: 'Cook pasta',
is_active: true,
created_at: new Date(),
},
{
id: '2',
description: 'Do laundry',
is_active: true,
created_at: new Date(),
},
{
id: '3',
description: 'Clean kitchen',
is_active: false,
created_at: new Date(),
},
];
getAll(): Promise<ToDo[]> {
return Promise.resolve(this.data);
}
get(id: string): Promise<ToDo> {
return Promise.resolve(this.data.filter((t) => t.id === id)[0]);
}
create(todo: ToDo): Promise<ToDo> {
this.data.push(todo);
return Promise.resolve(todo);
}
update(todo: ToDo): Promise<ToDo> {
const dataIndex = this.data.findIndex((t) => t.id === todo.id);
this.data[dataIndex] = todo;
return Promise.resolve(this.data[dataIndex]);
}
delete(id: string): Promise<boolean> {
this.data = this.data.filter((t) => t.id !== id);
return Promise.resolve(true);
}
}
Unit testing
Before we start, let us run the tests and coverage so we can compare the before and after results.
$ yarn test:cov
Let us start by adding unit tests to the controller, service and module.
Unit testing service
This is an example of what unit tests for a service may look like. Although it may appear extensive for a small service, it has several benefits. Each function is tested individually and, in the event of a failure, the test can pinpoint the specific point of failure. The use of "describe" blocks isolates each test, providing a level of abstraction and assurance they are not affected by other neighbouring tests.
import { Test, TestingModule } from '@nestjs/testing';
import { ToDo } from 'src/models/ToDo';
import { DataBaseService } from 'src/modules/database/database.service';
import { TodoModule } from './todo.module';
import { TodoService } from './todo.service';
describe('TodoService', () => {
let service: TodoService;
const mockDataBaseService = {
getAll: jest.fn(),
get: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [TodoModule],
})
.overrideProvider(DataBaseService)
.useValue(mockDataBaseService)
.compile();
service = module.get<TodoService>(TodoService);
});
it('should have the service defined', () => {
expect(service).toBeDefined();
});
describe('#getAll', () => {
beforeEach(() => {
jest.spyOn(mockDataBaseService, 'getAll');
});
it('should be defined', () => {
expect(service.getAll).toBeDefined();
});
it('should call the database', () => {
service.getAll();
expect(mockDataBaseService.getAll).toBeCalledTimes(1);
});
});
describe('#get', () => {
beforeEach(() => {
jest.spyOn(mockDataBaseService, 'get');
});
it('should be defined', () => {
expect(service.get).toBeDefined();
});
it('should call the database', () => {
service.get('1');
expect(mockDataBaseService.get).toBeCalledTimes(1);
});
});
describe('#create', () => {
beforeEach(() => {
jest.spyOn(mockDataBaseService, 'create');
});
it('should be defined', () => {
expect(service.create).toBeDefined();
});
it('should call the database', () => {
service.create({} as ToDo);
expect(mockDataBaseService.create).toBeCalledTimes(1);
});
});
describe('#update', () => {
beforeEach(() => {
jest.spyOn(mockDataBaseService, 'update');
});
it('should be defined', () => {
expect(service.update).toBeDefined();
});
it('should call the database', () => {
service.update({} as ToDo);
expect(mockDataBaseService.update).toBeCalledTimes(1);
});
});
describe('#delete', () => {
beforeEach(() => {
jest.spyOn(mockDataBaseService, 'delete');
});
it('should be defined', () => {
expect(service.delete).toBeDefined();
});
it('should call the database', () => {
service.delete('1');
expect(mockDataBaseService.delete).toBeCalledTimes(1);
});
});
describe('#markAsInActive', () => {
beforeEach(() => {
jest.spyOn(mockDataBaseService, 'get');
jest.spyOn(mockDataBaseService, 'update');
});
it('should be defined', () => {
expect(service.markAsInActive).toBeDefined();
});
describe('when inactive is called', () => {
it('should call the databaseService.get', () => {
expect(mockDataBaseService.get).toBeCalledTimes(1);
});
it('should call the databaseService.update', () => {
expect(mockDataBaseService.update).toBeCalledTimes(1);
});
});
});
});
Unit testing controller
Unit tests for controllers are similar to a service. Let us have a closer look at the get function, specifically.
get(@Param() params: { id: string }): Promise<ToDo> {
return this.todoService.get(params.id);
}
This function is responsible for reading the id from params, and invoking and returning the get function from service. So in our unit test, we test exactly the same. We are making sure when this function in the controller is being invoked, the relevant function in service is being called once.
import { Test, TestingModule } from '@nestjs/testing';
import { ToDo } from 'src/models/ToDo';
import { TodoController } from './todo.controller';
import { TodoService } from './todo.service';
describe('TodoController', () => {
let controller: TodoController;
let service: TodoService;
const mockTodoService = {
getAll: jest.fn(),
get: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
markAsInActive: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TodoService],
controllers: [TodoController],
})
.overrideProvider(TodoService)
.useValue(mockTodoService)
.compile();
controller = module.get<TodoController>(TodoController);
service = module.get<TodoService>(TodoService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('#getAll', () => {
beforeEach(() => {
jest.spyOn(service, 'getAll');
});
it('should be defined', () => {
expect(service.getAll).toBeDefined();
});
it('should call service.getAll', () => {
controller.getAll();
expect(service.getAll).toBeCalledTimes(1);
});
});
describe('#get', () => {
beforeEach(() => {
jest.spyOn(service, 'get');
});
it('should be defined', () => {
expect(service.get).toBeDefined();
});
it('should call service.get', () => {
controller.get({ id: '1' });
expect(service.get).toBeCalledTimes(1);
});
});
describe('#create', () => {
beforeEach(() => {
jest.spyOn(service, 'create');
});
it('should be defined', () => {
expect(service.create).toBeDefined();
});
it('should call service.create', () => {
controller.create({} as ToDo);
expect(service.create).toBeCalledTimes(1);
});
});
describe('#update', () => {
beforeEach(() => {
jest.spyOn(service, 'update');
});
it('should be defined', () => {
expect(service.update).toBeDefined();
});
it('should call service.update', () => {
controller.update({} as ToDo);
expect(service.update).toBeCalledTimes(1);
});
});
describe('#delete', () => {
beforeEach(() => {
jest.spyOn(service, 'delete');
});
it('should be defined', () => {
expect(service.delete).toBeDefined();
});
it('should call service.delete', () => {
controller.delete({ id: '1' });
expect(service.delete).toBeCalledTimes(1);
});
});
describe('#markAsInActive', () => {
beforeEach(() => {
jest.spyOn(service, 'markAsInActive');
});
it('should be defined', () => {
expect(service.markAsInActive).toBeDefined();
});
it('should call service.markAsInActive', () => {
controller.markAsInActive({ id: '1' });
expect(service.markAsInActive).toBeCalledTimes(1);
});
});
});
Unit testing module
Unit testing modules are comparatively easier. The responsibility of this test is to make sure that the relevant controllers and services are injected.
import { Test, TestingModule } from '@nestjs/testing';
import { TodoController } from './Todo.controller';
import { TodoModule } from './Todo.module';
import { TodoService } from './Todo.service';
describe('TodoModule', () => {
let module: TestingModule;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [TodoModule],
}).compile();
});
it('should compile the module', async () => {
expect(module).toBeDefined();
});
it('should have Todo components', async () => {
expect(module.get(TodoController)).toBeInstanceOf(TodoController);
expect(module.get(TodoService)).toBeInstanceOf(TodoService);
});
});
Now that we are finished with unit testing, let us run the coverage and compare the results. We can see that the coverage for statements, branches, functions and lines have increased significantly. We can talk a bit more about the coverage and jest setting at the end.
This coverage report shows us a clear picture of the coverage of tests on each file. The most important column is that labelled ‘Uncovered Lines’. You’ll see that, for example, todo.service.ts has uncovered lines of code from 32 to 34. We now have to write unit tests to make sure those lines are also covered. In certain scenarios, it might not be possible to get 100% coverage. The goal is to get maximum coverage, and 80% or higher is considered good. If we can achieve 90% or more, we have a very confident test suite. However, it’s worth noting that we should not achieve these results by excluding files in the config.
E2E testing
NestJS E2E testing involves testing the application end-to-end, starting from main.ts all the way to service. This is an easy way to test whether all the components are wired up correctly. This ensures the modules and its injections are in order, all the dependency injections are correct in controller and service, and confirms the routing is done as expected.
E2E testing Todo Module
E2E tests are written for each module. We have to make sure that we hit all the controller endpoints, which are technically all the routes configured through a module. So, now we are going to test the Todo Module end-to-end. We will be mocking the DatabaseService for the time being. Similar to the NestJS functions, we don’t need to test the database, as it is implied it works as expected.
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { TodoService } from 'src/modules/todo/todo.service';
import { AppModule } from 'src/app.module';
describe('TodoModule', () => {
let app: INestApplication;
const mockTodoService = {
getAll: jest.fn(),
get: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
markAsInActive: jest.fn(),
};
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(TodoService)
.useValue(mockTodoService)
.compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('GET: todo/:id', () => {
beforeEach(() => {
jest.spyOn(mockTodoService, 'get');
});
it('should return OK', async () => {
await request(app.getHttpServer()).get('/todo/1').expect(200, {});
});
});
describe('GET: todo/all', () => {
beforeEach(() => {
jest.spyOn(mockTodoService, 'getAll');
});
it('should return OK', async () => {
await request(app.getHttpServer()).get('/todo/all').expect(200, {});
});
});
describe('POST: todo', () => {
beforeEach(() => {
jest.spyOn(mockTodoService, 'create');
});
it('should return OK', async () => {
await request(app.getHttpServer()).post('/todo').expect(201, {});
});
});
describe('PUT: todo', () => {
beforeEach(() => {
jest.spyOn(mockTodoService, 'update');
});
it('should return OK', async () => {
await request(app.getHttpServer()).put('/todo').expect(200, {});
});
});
describe('PUT: todo/inactive/:id', () => {
beforeEach(() => {
jest.spyOn(mockTodoService, 'update');
});
it('should return OK', async () => {
await request(app.getHttpServer())
.put('/todo/inactive/:id')
.expect(200, {});
});
});
describe('DELETE: todo/:id', () => {
beforeEach(() => {
jest.spyOn(mockTodoService, 'delete');
});
it('should return OK', async () => {
await request(app.getHttpServer()).delete('/todo/:id').expect(200, {});
});
});
});
Now we can run the E2E tests.
$ yarn test:e2e
Super test
In the above E2E test set up, we can see that we are injecting the test bed with the modules. The E2E is not agnostic of NestJS and its components. We might have to override the components and services to make sure that the test can pass.
Now we need a test set up that is agnostic of the language or framework we have used. So we will run the application and our test will run against the running application. These tests can also be used to run against different environments. Since this test is framework agnostic, it will come handy if we decide to change the framework, for instance from NestJS to C#. This can also help us avoid regressions as it can be run against multiple environments.
import * as request from 'supertest';
const baseURL = 'http://localhost:3000/';
describe('Todo', () => {
const apiRequest = request(baseURL);
describe('GET: todo/:id', () => {
it('should have the response', async () => {
const response = await apiRequest.get('todo/1');
expect(response.status).toBe(200);
});
});
describe('GET: todo/all', () => {
it('should have the response', async () => {
const response = await apiRequest.get('todo/all');
expect(response.status).toBe(200);
});
});
describe('POST: todo', () => {
it('should have the response', async () => {
const response = await apiRequest.post('todo').send({});
expect(response.status).toBe(201);
});
});
describe('PUT: todo', () => {
it('should have the response', async () => {
const response = await apiRequest.put('todo').send({});
expect(response.status).toBe(200);
});
});
describe('PUT: todo/inactive/:id', () => {
it('should have the response', async () => {
const response = await apiRequest.put('todo').send({});
expect(response.status).toBe(200);
});
});
describe('DELETE: todo/:id', () => {
it('should have the response', async () => {
const response = await apiRequest.delete('todo/1');
expect(response.status).toBe(200);
});
});
});
Before we run the tests, we have to run the application. This is because this test will be hitting the actual endpoints of the application rather than the code itself. Note that in cases of authenticated endpoints, we might have to pass header or token so that the test can pass.
$ yarn test:supertest
There are a few things to keep an eye on here. Firstly, we are only checking the status of the response, however we can and should also check the response in here, which increases the reliability of the test.
You can also configure a helper which gives you a response to compare against while running on different environments. I have hardcoded the base URL here for the ease of the demo. Ideally, we need the base URL to be read from an environment config file.
Conclusion
Now that we have three sets of tests, we have achieved a higher level of confidence in updating existing implementations and introducing more features. These tests will help us in multiple ways. Firstly, they act like a technical documentation of what the expected behaviour is. Secondly, this will help us in alerting any unwanted bugs or regression that we might have been accidentally introduced. These tests should be added to the CI/CD pipelines so they can run against every change. The supertests can be very handy if we set them to run against all environments, if possible.
In upcoming blogs, we will explore how these tests can become even more helpful when we start to add new features to this existing code base.
Code: https://github.com/rohithart/nestjs-todo/tree/9bd7580e3ac43417cb49184b20919889d3b45bbc
NestJS Documentation: https://docs.nestjs.com/
Posted on March 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.