Unit testing a NestJS applications with Jest (Phần 1)

Unit testing a NestJS applications with Jest (Phần 1)

Dạo gần đây phải code NestJS khá nhiều nên cũng lang thang đi tìm hiểu unit tests cho NestJS application. Sau một hồi tha hương cầu thực trên mạng thì cũng thấy một vài bài khá hay nên mạnh dạn tổng hợp mang về đây viết. Vì bài hơi dài nên mình sẽ chia ra làm 2 phần để chi tiết hơn từng phần. Let 's go!
Có 5 bước chủ yếu ta cần phải làm để có đạt được như cái tiêu đề blog:
1. Tạo một ứng dụng NestJS application đơn giản.
2. Add unit test cases.
3. Run unit test cases.
4. Add end-to-end test cases.
5. Run all tests.

Vào từng bước nào:

1. Tạo một ứng dụng NestJS đơn giản

  • Để khởi tạo một ứng dụng NestJS, run nest new name_project trong VSCode terminal (trong đó name_project là tên ứng dụng muốn đặt nhé). Ví dụ mình sẽ đặt là StudentGPA:
nest new StudentGPA
  • Tiếp theo tạo 2 services bằng cách thực hiện lệnh:
nest g service student
nest g service api
  • Chạy ứng dụng và sẽ được kết quả như này:

  • Cấu trúc thư mục sẽ có dạng:

  • Giải thích qua về ứng dụng: Với đầu vào là firstNamesecondName của sinh viên, kết quả trả về GPA của sinh viên đó. StudentService sẽ sử dụng ApiService để lấy thông tin sinh viên đó.

  • Unit testing sẽ được thực hiện trong NestJS bởi framework JestJS.

  • Bắt tay vào code nhé! Đầu tiên mình sẽ thêm hàm getGPA trong AppController.

import { Controller, Get, HttpException, Query } from '@nestjs/common';
import { AppService } from './app.service';
import { StudentService } from './student/student.service';

@Controller('student')
export class AppController {
  constructor(
    private readonly appService: AppService,
    private readonly studentService: StudentService,
  ) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('/gpa')
  async getStudentGpa(
    @Query('firstName') firstName: string,
    @Query('lastName') lastName: string,
  ): Promise<number> {
    if (!firstName || !lastName) {
      throw new HttpException('Incomplete student information', 400);
    }
    return await this.studentService.getGpa(firstName, lastName);
  }
}
  • Và giờ mình cần tạo hàm getGPA trong studentService:
import { HttpException, Injectable } from '@nestjs/common';
import { ApiService } from '../api/api.service';

export interface Student {
  name: string;
  grades: number[];
}

@Injectable()
export class StudentService {
  constructor(private apiService: ApiService) {}

  public async getGpa(firstName: string, lastName: string): Promise<number> {
    const student: Student = await this.apiService.getStudent(
      firstName,
      lastName,
    );
    if (!student || !student.grades) {
      throw new HttpException('Cannot find student or student grades', 404);
    }
    let gpa = 0;
    for (const grade of student.grades) {
      gpa += grade / student.grades.length;
    }
    return gpa;
  }
}
  • Tiếp theo là tạo hàm getStudent trong apiService:
import { HttpService, Injectable } from '@nestjs/common';
import { Student } from '../student/student.service';

@Injectable()
export class ApiService {
  constructor(private http: HttpService) {}
  async getStudent(firstName: string, lastName: string): Promise<Student> {
    const url = `../get-student?firstName=${firstName}&lastName=${lastName}`;
    const response = await this.http.get(url).toPromise();
    return response.data;
  }

  getHello(): string {
    return 'Hello World!';
  }
}
  • Chuẩn bị xong, run nest start nào.

2. Add unit test cases

  • Tìm hiểu lại một số kiến thức về unit testing trước khi bắt đầu nhé. Unit testing tập trung vào viết tests cho các phần nhỏ nhất (phần lớn là các functions được định nghĩa trong classes). VD MethodAclassA có thể gọi được MethodBclassB. Tuy nhiên, unit test của MethodA chỉ tập trung vào logic của MethodA, không phải MethodB.
  • Unit tests không nên phụ thuộc vào môi trường mà chúng chạy. Để viết những unit tests biệt lập, ta sẽ mock all những dependencies của method/service. Và trong unit test StudentService, ta sẽ mock AppService bằng cách tạo class ApiServiceMock.
    • Test Doubles: Fakes, stubs, và mock all là thuộc tính của test doubles. Một test double là một object hay system mà bạn dùng trong test để thay thế cho một cái gì đó.
  • Fakes: Một object với khả năng giới hạn(cho mục đích testing), VD: fake web service. Fake có hoạt động kinh doanh. Bạn có thể khiến Fake hoạt động theo những cách khác nhau bằng cách cung cấp cho nó những data khác nhau. Fakes có thể được sử dụng khi bạn không thể dùng triển khai thực tế trong test của mình.
  • Stub: một object cung cấp các câu trả lời được xác định để method gọi. Stub không có logic và chỉ trả về những gì bạn yêu cầu trả về.
    Nếu chưa hiểu lắm về fake/mock/stub, bạn có thể tham khảo thêm tại here .
  • Spy: Thường được sử dụng để đảm bảo một phương thức cụ thể đã được gọi.
  • Đối với unit tests, ta sẽ tập chung vào 3 classes:
  • app.controller.spect.ts (Test class của AppController)
  • student.service.spec.ts (Test class của StudentService)
  • api.service.spec.ts (Test class của ApiService)
  • Có nhiều cách tiếp cận mà chúng ta có thể sử dụng ở đây. Một trong số đó là sử dụng Mock functions được cung cấp bởi Jest framework. Mock functions cho phép ta test links giữa code bằng cách xóa việc triển khai thực tế của một function. Một số khác thì sử dụng mock classes.
  • Hãy cùng khám phá cách tiếp cận mock funtion trong app.controller.spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { StudentService } from './student/student.service';
describe('AppController', () => {
  let appController: AppController;
  let spyService: StudentService;
  beforeEach(async () => {
    const ApiServiceProvider = {
      provide: StudentService,
      useFactory: () => ({
        getGpa: jest.fn(() => 4.5),
      }),
    };
    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [AppService, ApiServiceProvider],
    }).compile();
    appController = app.get<AppController>(AppController);
    spyService = app.get<StudentService>(StudentService);
  });
  describe('root', () => {
    it('should return "Hello World!"', () => {
      expect(appController.getHello()).toBe('Hello World!');
    });
  });
  describe('getGPA', () => {
    it('should call getGPA for a student', async () => {
      const firstName = 'Joe';
      const secondName = 'Foo';
      appController.getStudentGpa(firstName, secondName);
      expect(spyService.getGpa).toHaveBeenCalled();
    });
  });
  describe('getGPA', () => {
    it('should retrieve getGPA for a student', async () => {
      const firstName = 'Joe';
      const secondName = 'Foo';
      expect(spyService.getGpa(firstName, secondName)).toBe(4.5);
    });
  });
});
  • Line #1: Test class từ @nestjs/testing cung cấp ngữ cảnh thực thi test bằng cách mock toàn bộ thời gian chạy Nest. Nó cũng cung cấp các hook để mock và ghi đè.
  • Line #8: Biến spyService khai báo để theo dõi lệnh gọi của StudentService.
  • Line #17-20: Phương thức biên dịch của đối tượng này khởi động một module với các dependencies của nó và trả về một module chuẩn bị cho testing.
  • Line #23: spyService được định nghĩa.
  • Line #13-14: mock function cho getGPA được định nghĩa.
  • Line #28: Xác minh getHello() method của AppController.
  • Line #35: Xác minh getGPA của StudentService đã được gọi.
  • Line #44: Xác minh giá trị trả về của getGPA đúng như giá trị mong đợi.
  • Tiếp theo, hãy cùng khám phá cách tiếp cận mock funtion trong student.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { StudentService } from './student.service';
import { ApiService } from '../api/api.service';
class ApiServiceMock {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  getStudent(_firstName: string, _lastName: string) {
    return {
      name: 'Jane Doe',
      grades: [3.7, 3.8, 3.9, 4.0, 3.6],
    };
  }
}
describe('StudentService', () => {
  let studentService: StudentService;
  beforeEach(async () => {
    const ApiServiceProvider = {
      provide: ApiService,
      useClass: ApiServiceMock,
    };
    const module: TestingModule = await Test.createTestingModule({
      providers: [StudentService, ApiServiceProvider],
    }).compile();
    studentService = module.get<StudentService>(StudentService);
  });
  it('StudentService - should be defined', () => {
    expect(studentService).toBeDefined();
  });
  describe('getGpa', () => {
    it('should get student GPA', async () => {
      const expectedGpa = 3.8;
      const gpa = await studentService.getGpa('Jane', 'Doe');
      expect(gpa).toEqual(expectedGpa);
    });
  });
});
  • Line #5-13: Mock class.
  • Line #23: Phương thức createTestingModule của Test class lấy một đối tượng module metadata và trả về một phiên bản(instance) TestingModule.
  • Line #24: Việc triển khai mock class của ApiService được cung cấp để sử dụng thay vì triển khai thực tế.
  • Line #31: Xác minh rằng đối tượng studentService được định nghĩa.
  • Line #38: Xác minh rằng GPA nhận được giống với GPA mong chờ.
  • Cuối cùng là api.serivce.spec.ts, test class của ApiService.
import { Test, TestingModule } from '@nestjs/testing';
import { ApiService } from './api.service';
import { HttpModule } from '@nestjs/common';
describe('ApiService', () => {
  let service: ApiService;
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [ApiService],
      imports: [HttpModule],
    }).compile();
    service = module.get<ApiService>(ApiService);
  });
  it('ApiService - should be defined', () => {
    expect(service).toBeDefined();
  });
});

Phần 1 này mình chỉ làm đến đây. Sẽ ra phần 2 là 3 mục còn lại sớm nhất có thể. Mọi người đọc và cho mình xin góp ý. Tài liệu tham khảo có thể xem ở đây:
https://nishabe.medium.com/unit-testing-a-nestjs-app-in-shortest-steps-bbe83da6408
https://blog.theodo.com/2019/06/test-nestjs-with-jest-typescript/