Basic and effective NestJS Testing implementation guide

Introduction

Implementing testing in software development not only helps detect bugs early but also ensures system stability when performing upgrades or changing code. Testing helps programmers feel more confident, minimizes logic error risks, and creates a living document of how modules operate.

  • In NestJS, we usually focus on three main concepts:
  • Unit Test helps test each class or function independently
  • End-to-End (e2e) Test tests the entire operation flow from request to response through a real server
  • Test Coverage is an index measuring the percentage of source code that has been tested by test suites.

Prerequisites

In this article, I will continue to use the code from previous articles to implement tests; you can review those articles to get the source code to continue, or you can write similar tests based on the content according to your needs.


Detail

Update file package.json, focusing on the jest field

{
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s",
"!**/node_modules/**",
"!**/*.module.ts",
"!**/*.entity.ts",
"!**/*.dto.ts",
"!**/*.interface.ts",
"!main.ts",
"!repl.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"coverageReporters": [
"text",
"lcov",
"html"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
}
}
}

The above configuration sets up Jest to automatically find .spec.ts files in the src directory, and at the same time determines the files to collect coverage (excluding config, module, dto files). Especially, the coverageThreshold section sets a mandatory rule that the project must reach at least 80% coverage for all criteria to be considered passing the check.


Update file config cá»§a jest-e2e.json

{
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
}
}

This is a specific configuration file for End-to-End testing. It directs Jest to find files ending in .e2e-spec.ts and sets up moduleNameMapper so Jest can understand absolute paths starting with src/ when running tests from the test directory located outside the src directory.


Here I will explain the meaning of the moduleNameMapper field as follows:

  • Left side - Regex Pattern: ^src/(.*)$
    • ^src/: Find all strings starting with src/.
    • (.*): Parentheses create a Capture Group. It will "catch" the entire remaining part after src/.
    • $: End of string.
    • Example: If you import src/service/s3.service, the Capture Group (.*) will be service/s3.service.
  • Right side - Target Path: <rootDir>/../src/$1
    • <rootDir>: In the test/jest-e2e.json file, rootDir is usually the test directory.
    • /../: Jump out one level (from the test directory to the project root directory).
    • /src/: Go into the real src directory.
    • $1: This is the most important part. It will pour the content that the Capture Group on the left side "caught" into here.
    • Example: service/s3.service will be substituted into $1.


Actual workflow:

When Jest sees this line of code in your test file:

import { S3Service } from 'src/service/s3.service';

It will compare with the config and perform the steps:

  • Matches the pattern ^src/(.*)$.
  • Takes the service/s3.service part and assigns it to $1.
  • Converts the path to: {project}/test/../src/service/s3.service.
  • Shortens it back to: {project}/src/service/s3.service.


Update file test-setup.ts

import {Test, TestingModule} from '@nestjs/testing'
import {INestApplication, ValidationPipe} from '@nestjs/common'
import {AppModule} from '../src/app.module'

export class TestSetup {
static async createApp(): Promise<INestApplication> {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile()

const app = moduleFixture.createNestApplication()
app.useGlobalPipes(new ValidationPipe())
await app.init()
return app
}
}


Create file test.controller.spec.ts for unit test

import {createMock} from '@golevelup/ts-jest'
import {BadRequestException, Logger} from '@nestjs/common'
import {Test, TestingModule} from '@nestjs/testing'
import {TestController} from 'src/controller/test.controller'

describe('TestApiController', () => {
let controller: TestController

const mockLogger = {
log: jest.fn(),
error: jest.fn(),
}

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TestController],
providers: [
{
provide: Logger,
useValue: mockLogger,
},
],
})
.useMocker(token => {
return createMock(token)
})
.compile()

controller = module.get<TestController>(TestController)
;(controller as any).logger = mockLogger
})

it('should be defined', () => {
expect(controller).toBeDefined()
})

describe('getText', () => {
it('should return "test-1"', async () => {
const result = await controller.getText()
expect(result).toBe('test-1')
expect(mockLogger.log).toHaveBeenCalledWith('test1')
})
})

describe('throwError', () => {
it('should throw BadRequestException', async () => {
jest.spyOn(controller as any, 'tmpFunc').mockImplementation(() => {
throw new BadRequestException('BadRequestException tmpFunc')
})

expect(controller.throwError()).rejects.toThrow(BadRequestException)
expect(mockLogger.error).toHaveBeenCalledWith('test2')
})
})

describe('query & params', () => {
it('should return double string for query', async () => {
expect(await controller.query('abc')).toBe('abcabc')
})

it('should return sum for queryPipe (number)', async () => {
expect(await controller.queryPipe(5)).toBe(10)
})

it('should return concatenated string for multi-param', async () => {
const params = {value1: 'a', value2: 'b'}
expect(await controller.getMultiParam(params)).toBe('ab')
})
})

describe('CRUD operations', () => {
it('should return success for create', async () => {
const dto = {name: 'test'} as any
const result = await controller.create(dto)
expect(result).toEqual({message: 'Successful', data: dto})
})

it('should return id for put/delete', async () => {
expect(await controller.put(1)).toEqual({message: 'Successful', id: 1})
expect(await controller.delete(2)).toEqual({message: 'Successful', id: 2})
})
})

describe('uploadFile', () => {
it('should return file info and constructed URL', () => {
const mockFile = {filename: 'test.png'} as any
const mockReq = {
protocol: 'http',
get: jest.fn().mockReturnValue('localhost:3000'),
}
const result = controller.uploadFile(mockFile, mockReq)
expect(result).toEqual({
filename: 'test.png',
url: 'http://localhost:3000/public/test.png',
})
})
})
})

This unit test code checks each method in TestController separately. It uses Mocking techniques to simulate Logger and other dependencies, helping tests run fast and independent of actual external services, while accurately checking return results as well as Exceptions.


Create file test.e2e-spec.ts for end-to-end test

import {INestApplication} from '@nestjs/common'
import {existsSync, unlinkSync} from 'fs'
import {join} from 'path'
import request from 'supertest'
import {App} from 'supertest/types'
import {TestSetup} from './test-setup'

describe('TestController (e2e)', () => {
let app: INestApplication<App>
const prefix = '/test'

beforeEach(async () => {
app = await TestSetup.createApp()
})

afterAll(async () => {
await app.close()
})

it('/get-text (GET)', () => {
return request(app.getHttpServer())
.get(`${prefix}/get-text`)
.expect(200)
.expect('test-1')
})

it('/query (GET)', () => {
return request(app.getHttpServer())
.get(`${prefix}/query`)
.query({value: 'abc'})
.expect(200)
.expect('abcabc')
})

it('/query-pipe (GET)', () => {
return request(app.getHttpServer())
.get(`${prefix}/query-pipe`)
.query({value: '5'})
.expect(200)
.expect('10')
})

it('/param/:value (GET)', () => {
return request(app.getHttpServer())
.get(`${prefix}/param/10`)
.expect(200)
.expect('20')
})

it('/multi-param/:value1/:value2 (GET)', () => {
return request(app.getHttpServer())
.get(`${prefix}/multi-param/hello/world`)
.expect(200)
.expect('helloworld')
})

it('/create (POST)', () => {
const payload = {name: 'NestJS'}
return request(app.getHttpServer())
.post(`${prefix}/create`)
.send(payload)
.expect(201)
.expect(res => {
expect(res.body.message).toBe('Successful')
expect(res.body.data).toEqual(payload)
})
})

it('/put/:id (PUT)', () => {
return request(app.getHttpServer())
.put(`${prefix}/put/123`)
.expect(200)
.expect({message: 'Successful', id: 123})
})

it('/delete/:id (DELETE)') => {
return request(app.getHttpServer())
.delete(`${prefix}/delete/99`)
.expect(200)
.expect({message: 'Successful', id: 99})
})

it('/upload (POST)', async () => {
const response = await request(app.getHttpServer())
.post(`${prefix}/upload`)
.attach('file', filePath)
.expect(201)
expect(response.body).toHaveProperty('filename')
expect(response.body.url).toContain('/public/')
})

it('/throw-error (GET) - Should fail', () => {
return request(app.getHttpServer())
.get(`${prefix}/throw-error`)
.expect(400)
.expect({
statusCode: 400,
message: 'BadRequestException tmpFunc',
error: 'Bad Request',
})
})
})

This e2e test code uses the supertest library to send real HTTP requests (GET, POST, PUT, DELETE) to the server running in the test environment. It checks the entire lifecycle of a request, including passing through data processing Pipes and Validation, to ensure the API responds with the correct status code and desired data structure.


Result when running unit test

$ yarn test
yarn run v1.22.22
$ jest
PASS src/app.controller.spec.ts
PASS src/test/test.controller.spec.ts

Test Suites: 2 passed, 2 total
Tests: 10 passed, 10 total
Snapshots: 0 total
Time: 2.367 s
Ran all test suites.
Done in 3.13s.


Result when running end-to-end test

$ yarn test:e2e
$ jest --config ./test/jest-e2e.json
PASS test/app.e2e-spec.ts
PASS test/test.e2e-spec.ts

Test Suites: 2 passed, 2 total
Tests: 11 passed, 11 total
Snapshots: 0 total
Time: 3.92 s
Ran all test suites.
Done in 4.42s.


Result when running test coverage, you can see that the config for coverageThreshold has worked when the test coverage has not met the thresholds I requested

$ yarn test:cov
yarn run v1.22.22
$ jest --coverage
PASS src/app.controller.spec.ts
PASS src/test/test.controller.spec.ts (5.327 s)
--------------------------------|---------|----------|---------|---------|----------------------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------------------|---------|----------|---------|---------|----------------------------------
All files | 13.65 | 7.9 | 12.6 | 12.63 |
src | 100 | 75 | 100 | 100 |
app.controller.ts | 100 | 75 | 100 | 100 | 6
app.service.ts | 100 | 100 | 100 | 100 |
src/controller | 24.29 | 9.63 | 23.25 | 26.97 |
test.controller.ts | 71.66 | 61.53 | 62.5 | 73.21 | 65,97-99,136-137,142-147,152-157
src/database | 0 | 0 | 0 | 0 |
database.service.ts | 0 | 0 | 0 | 0 | 1-51
src/exception-filter | 46.15 | 75 | 50 | 36.36 |
test-exception.filter.ts | 46.15 | 75 | 50 | 36.36 | 15-26
src/factory | 0 | 0 | 0 | 0 |
ssm-config.factory.ts | 0 | 0 | 0 | 0 | 1-29
src/guard | 15.78 | 17.64 | 25 | 14.28 |
test.guard.ts | 54.54 | 33.33 | 50 | 44.44 | 14-24
src/interceptor | 20.83 | 100 | 0 | 15 |
test.interceptor.ts | 45.45 | 100 | 0 | 33.33 | 15-26
src/middleware | 0 | 0 | 0 | 0 |
origin-auth.middleware.ts | 0 | 0 | 0 | 0 | 1-21
src/pipe | 50 | 0 | 0 | 33.33 |
test.pipe.ts | 50 | 0 | 0 | 33.33 | 11-15
src/service | 3.55 | 3.84 | 0 | 2.41 |
test-entity.service.ts | 0 | 0 | 0 | 0 | 1-19
src/strategy | 0 | 0 | 0 | 0 |
saml2.strategy.ts | 0 | 0 | 0 | 0 | 1-37
--------------------------------|---------|----------|---------|---------|----------------------------------
Jest: "global" coverage threshold for statements (80%) not met: 13.65%
Jest: "global" coverage threshold for branches (80%) not met: 7.9%
Jest: "global" coverage threshold for lines (80%) not met: 12.63%
Jest: "global" coverage threshold for functions (80%) not met: 12.6%

Test Suites: 2 passed, 2 total
Tests: 10 passed, 10 total
Snapshots: 0 total
Time: 11.651 s, estimated 44 s
Ran all test suites.


When you open the generated html file in the test report, the result will be as follows:


Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Deploying a NodeJS Server on Google Kubernetes Engine

Kubernetes Deployment for Zero Downtime

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

NodeJS Practice Series

Sitemap