JHyeok

NestJS에서 단위 테스트 작성하기

November 01, 2020☕️ 5 min read

이 글에서 사용하는 단위 테스트 코드는 여기에서 확인할 수 있습니다. 하지만 저장소를 Mock/Stub 처리해서 작성한 단위 테스트입니다. 저장소를 Mock/Stub 처리하지 않고 작성한 테스트 코드는 여기에서 확인할 수 있습니다.

TestingModule

NestJS는 내장된 종속성 주입을 사용해서 쉽게 테스트 코드를 작성할 수 있도록 도와준다. 종속성 주입은 일반적으로 클래스가 아닌 인터페이스를 기반으로 하지만, TypeScript에서 인터페이스는 런타임이 아닌 컴파일 시간에만 사용할 수 있으므로 나중에 신뢰할 수가 없기 때문에 NestJS에서는 클래스 기반 주입을 사용하는 것이 일반적이다.

import { Test, TestingModule } from '@nestjs/testing';
describe('UserService', () => {
  let userService: UserService;
  let userRepository: UserRepository;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UserService, UserRepository],
    }).compile();

    userService = module.get<UserService>(UserService);
    userRepository = module.get<UserRepository>(UserRepository);
  });
}

NestJS에서는 특정 도구를 강제하지는 않지만 Jest를 기본 테스트 프레임워크로 제공해주며 테스팅 패키지도 제공하기 때문에 개발자가 다른 도구를 찾는데 소모하는 리소스를 줄일 수 있다.

NestJS에서 제공하는 @nestjs/testing 패키지를 사용하면 테스트에 사용되는 종속성만 선언해서 모듈을 만들고 해당 모듈로 UserService, UserRepository를 가져올 수 있다.

Jest Mocking

const userRepositorySaveSpy = jest
  .spyOn(userRepository, 'save')
  .mockResolvedValue(savedUser);

Jest에서는 모킹(mocking) 함수들을 제공하고 있다. Mock은 단위 테스트를 작성할 때, 해당 코드가 의존하는 부분을 가짜(mcok)로 대체하는 기법이다. 일반적으로는 테스트하려는 코드가 의존하는 부분을 직접 생성하기가 너무 부담스러울 때 Mock이 사용된다. jest.spyOnjest.fn과 유사한 모의 함수를 만들지만 함수 호출을 추적할 수 있다는 점에서 다르다. 위 코드에서는 spyOn으로 userRepositorysave 함수 호출을 모의하고 이 모의된 함수는 mockResolvedValue를 사용해서 savedUser를 반환하도록 정의하고 있다.

import * as faker from 'faker';
const firstName = faker.lorem.sentence();
const lastName = faker.lorem.sentence();

faker를 사용해서 가짜로 테스트에 필요한 데이터들을 만들어 줄 수 있다. 개인적으로 faker로 가짜 데이터를 만드는 방법은 사용하지 않는 것을 추천한다.

NestJS에서 단위 테스트 작성

유저를 수정하는 메서드의 단위 테스트를 작성할 것이다.

async updateUser(
  id: number,
  requestDto: UserUpdateRequestDto,
): Promise<User> {
  const user = await this.userRepository.findOne({
    where: {
      id: id,
    },
  });

  if (_.isEmpty(user) === true) {
    throw new NotFoundException(Message.NOT_FOUND_USER);
  }

  const { firstName, lastName, isActive } = requestDto;

  user.update(firstName, lastName, isActive);

  return this.userRepository.save(user);
}

UserServiceupdateUser 메서드를 테스트하려고 하는데, 이 메서드에서는 두 가지를 테스트해야 한다. 유저 id에 해당하는 유저가 있으면 성공적으로 수정하고 해당하는 유저가 없을 경우에는 실패하는 로직에 대해서 검증이 필요하다.

describe('UserService', () => {
  describe('updateUser', () => {
    it('생성되지 않은 유저의 id가 주어진다면 유저를 찾을 수 없다는 예외를 던진다', async () => {
      const userId = 1;
      const requestDto: UserUpdateRequestDto = {
        firstName: '길동',
        lastName: '김',
        isActive: false,
      };
      jest.spyOn(userRepository, 'findOne').mockResolvedValue(undefined);
      const result = async () => {        await userService.updateUser(userId, requestDto);      };
      await expect(result).rejects.toThrowError(        new NotFoundException('유저 정보를 찾을 수 없습니다.'),      );    });
  });
})

위의 단위 테스트 코드에서는 생성되지 않은 유저를 수정할 때는 findOne 메서드가 null의 결괏값을 반환할 거라고 Stub 한다. updateUser 메서드는 가짜로 null의 값이 반환되는 줄 알고 유저가 null 일 때 NotFoundException의 예외를 던진다. Jest에서는 rejectstoThrowError를 사용해서 이 코드가 NotFoundException을 던지는지 검증할 수 있다. should에서 제공하는 rejectedWith와 비슷하다.

describe('UserService', () => {
  describe('updateUser', () => {
    it('생성된 유저의 id가 주어진다면 해당 id의 유저를 수정하고 수정된 유저를 반환한다', async () => {
      const userId = 1;
      const requestDto: UserUpdateRequestDto = {
        firstName: '길동',
        lastName: '김',
        isActive: false,
      };
      const existingUser = User.of({
        id: userId,
        firstName: '재혁',
        lastName: '김',
        isActive: true,
      });
      const savedUser = User.of({
        id: userId,
        ...requestDto,
      });
      const userRepositoryFindOneSpy = jest        .spyOn(userRepository, 'findOne')        .mockResolvedValue(existingUser);      const userRepositorySaveSpy = jest        .spyOn(userRepository, 'save')        .mockResolvedValue(savedUser);
      const result = await userService.updateUser(userId, requestDto);

      expect(userRepositoryFindOneSpy).toHaveBeenCalledWith({
        where: {
          id: userId,
        },
      });
      expect(userRepositorySaveSpy).toHaveBeenCalledWith(savedUser);
      expect(result).toEqual(savedUser);
    });
  });
})

updateUser 메서드에서 id에 해당하는 유저를 찾아서 유저를 수정했다는 로직의 테스트이다. findOne 메서드는 미리 정의해놓은 existingUser를 반환할 거라고 Stub 하고 이 반환된 값을 수정해서 저장하면 savedUser를 반환할 것이라고 Stub 한다. 그리고 오류가 없이 정상적으로 처리된 내용을 result 변수의 값에 담고 Jest의 expect로 검증한다. 먼저, .toHaveBeenCalledWith는 모의 함수가 특정 인수로 호출되었는지 확인하는 데 사용할 수 있고, .toEqual로 개체의 모든 속성을 재귀적으로 비교한다.

UserService의 유저를 수정하는 코드의 일부분을 살펴보았다. 전체 코드를 확인하려면 여기에서 확인할 수 있다.

nestjs-unit-test

마치며

회사에서는 Mochasinon.js를 사용해서 테스트 코드를 작성했다. 이번에는 Jest와 Jest에서 제공하는 Mock Functions을 사용해서 테스트 코드를 작성해보았는데 개인적으로 Jest에서 Stub을 하기 위해서 spyOn을 사용하는 방식이 번거롭다고 느껴졌다. 하지만 Jest는 Test Runner와 Assertion Library와 같은 기타 도구들이 기본적으로 제공되는 것이 장점이라고 느껴졌다.

이 글을 작성한 이후에 classicist, mockist에 대해서 알게 되었습니다. 이 두 가지에 대해서 어떤 것이 좋은지 고민을 하고 있으시다면 이규원님이 작성하신 정말로 테스트 대역이 필요한가를 한 번 읽어보시기를 추천합니다.

Reference


JHyeok

JaeHyeok Kim

Written by JaeHyeok Kim
Github

© 2019 - 2022, Built with Gatsby