End to end testing (e2e) in NestJS

Reading Time: 4 minutes

Loading

This is part 3 of Use TypeOrm Custom Repository in NestJS and this post is about end- to-end testing (e2e) of NestJS endpoints.

In part one and part two, controllers, services and custom repositories are tested and they pass all the test cases. The final step is to validate HTTP requests between frontend and backend and the simulated application flows should always return the expected responses. This type of testing is called end-to-end testing because the application flow happens from the beginning to the end.

An analogy of e2e testing is my vacation to Canada. Whenever I visit Canada, my father requests to buy canned salmon, my old sister and I respond by driving to Costco warehouse to buy a pack of canned salmon and a few bags of Lays Ketchup chips. They are in my baggage and import to Hong Kong via cargo.

User API

In this post, I am going to do e2e testing on User API . The api is defined in user controller and consisted of four endpoints.

// user.controller.ts

// return all users
GET /user

// return user and the reports created by the user
GET /user/:id/report

// return user matching user id
GET /user/:id

// create new user
POST /user

Similar to unit tests, NestJS uses Jest and Supertest to write e2e test cases and make HTTP requests respectively, and validate status code and response.

Set up 1: Generate constants and mock data

Install uuid library that provides function to generate unique uuid.

npm install uuid
npm install --save @types/uuid
// constant.mock.ts

import { User } from '@/entities'
import { v4 } from 'uuid'

export const now = new Date()
export const userId = v4()
export const userId2 = v4()
export const reportId = v4()
export const newUserId = v4()
// user.mock.ts

import { CreateUserDto } from '@/user'
import { Report, User } from '@/entities'
import { newUserId, now, reportId, userId, userId2 } from './constant.mock'

// For brevity, delete initialization codes

export const allUsers: User[] = [
  {
    id: userId,
    name: 'John',
    lastname: 'Doe',
    age: 10,
    createdAt: now,
    updatedAt: now,
    version: 1,
    reports: [],
  },
  {
    id: userId2,
    name: 'Jane',
    lastname: 'Doe',
    age: 15,
    createdAt: now,
    updatedAt: now,
    version: 1,
    reports: [],
  },
]

export const expectedUser = {
  id: userId,
  name: 'John',
  lastname: 'Doe',
  age: 10,
  createdAt: now.toISOString(),
  updatedAt: now.toISOString(),
  version: 1,
  reports: [],
}

export const userService = {
  getUsers: () => allUsers,
  getUser: (id: string) => allUsers.find((user) => user.id === id),
  createUser: (dto: CreateUserDto) => {
    const newUser = {
      ...dto,
      id: newUserId,
      createdAt: now,
      updatedAt: now,
      version: 1,
      reports: [],
    }
    return newUser
  },
  getUserWithReports: (id: string) => {
    ...
  },
}

Set up 2: Create Testing Module and Initialize Test App

beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      controllers: [UserController],
      providers: [UserService],
    })
      .overrideProvider(UserService)
      .useValue(userService)
      .compile()

    app = moduleFixture.createNestApplication()
    app.useGlobalPipes(new ValidationPipe())
    await app.init()
})
.overrideProvider(UserService).useValue(userService)

replaces the real user service with mocked service

app.useGlobal(new ValidationPipe())

validates data transfer object (DTO) and throws exception when property fails the condition of class-validator decorators.

Example 1: Test cases to validate GET /user/:id

describe('/user/:id (GET)', () => {
   it('should return user', () => {
      return request(app.getHttpServer())
         .get(`/user/${userId}`)
         .expect(HttpStatus.OK)
         .expect(expectedUser)
    })

    it('should return undefined', () => {
      const id = v4()
      return request(app.getHttpServer())
          .get(`/user/${id}`)
          .expect(HttpStatus.OK)
          .expect({})
    })
}

The first test case validates that request(app.getHttpServer()).get(`/user/${userId}`) returns 200 status and calls getUser() to return user with matching user id.

The second test case validates that request(app.getHttpServer()).get(`/user/${userId}`) returns 200 status and calls getUser() to return undefined user.

Example 2: Negative test cases to validate POST /user

CreateUserDto requires non-empty first name, last name and non-negative age. If invalid DTO is sent in the request, the response should return 400 status. Lets write some test cases to validate the response of the requests

// create-user.dto.ts
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'

export class CreateUserDto {
  @IsNotEmpty()
  @IsString()
  name: string

  @IsNotEmpty()
  @IsString()
  lastname: string

  @IsNotEmpty()
  @Min(0)
  @IsNumber()
  age: number
}
describe('/user (POST)', () => {
    it('should throw error when age is negative', () => {
      const dto = {
        name: 'John',
        lastname: 'Doe',
        age: -1,
      }
      return request(app.getHttpServer())
          .post('/user')
          .send(dto)
          .expect(HttpStatus.BAD_REQUEST)
    })

    it('should throw error when name is blank', () => {
      const dto = {
        name: '',
        lastname: 'Doe',
        age: 62,
      }
      return request(app.getHttpServer())
          .post('/user')
          .send(dto)
          .expect(HttpStatus.BAD_REQUEST)
    })

})

Example 3: Positive test case to validate POST /user

describe('/user (POST)', () => {
    it('should create and return new user', () => {
      const dto = {
        name: 'John',
        lastname: 'Doe',
        age: 10,
      }
      return request(app.getHttpServer())
        .post('/user')
        .send(dto)
        .expect(HttpStatus.CREATED)
        .expect({
          ...dto,
          id: newUserId,
          createdAt: now.toISOString(),
          updatedAt: now.toISOString(),
          version: 1,
          reports: [],
        })
    })
})

When DTO is valid, POST /user is successful and it returns 201 status and a new user object. HTTP converts date time values of response to string; therefore, createAt and updatedAt become now.toISOString().

.expect(HttpStatus.CREATED)

returns 201 status

.expect({
    ...dto,
    id: newUserId,
    createdAt: now.toISOString(),
    updatedAt: now.toISOString(),
    version: 1,
    reports: [],
 })

The test case expects createUser() is called to return a new user object; it has new user id and data of DTO.

Final thoughts

E2e testing in NestJS is feasible when you understand the patterns of mocking and testing different HTTP methods (GET, POST, PATCH, DELETE). After grunt work is laid down, e2e test cases are described in several lines of codes. Happy path expects 200/201 status code and an object in response. Fail path expects 400-level or 500-level status code and an error message.

Test automation takes efforts initially but it can be used continuously to catch errors when API changes; therefore, it is a good investment for any developer and development team.

Resources:

  1. Repo: https://github.com/railsstudent/nest-poc-standard-version
  2. https://docs.nestjs.com/fundamentals/testing#end-to-end-testing