백엔드 개발자(node.js)가 되는 과정

Nestjs 기반 테스트코드 작성법 공부중 - 2일 차 회고

soopy 2023. 12. 13. 01:55
728x90

지난 시간 Nest.js 공식 문서를 참조하여 내가 만든 간단한 API를 기반으로 테스트 코드를 아래와 같이 무작정 작성했다.
아래 테스트코드에 담긴 의미는 "컨트롤러가 서비스에 잘 연결되어 있나?" 이다.
아래 코드는 현재 문제가 많은 상태이며 오늘 어떤 문제게 직면했고, 어떻게 수정했는지 살펴보고자 한다.

 

import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { User } from './user.entity';
import { UserController } from './user.controller';

describe('UserService', () => {
  let userService: UserService;
  let userController: UserController;

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

    userService = module.get<UserService>(UserService);
    userController = module.get<UserController>(UserController);
  });

  describe('findAll', () => {
    const users: User[] = [
      {
        id: 'uuid1',
        email: 'testemail1@gmail.com',
        password: 'abcd1234',
        createdAt: new Date('2023-01-01'),
        updatedAt: new Date('2023-01-01'),
        deletedAt: null,
        album: [],
      },
    ];

    it('should return an array of users data', async () => {
      jest.spyOn(userService, 'findAll').mockResolvedValue(users);
      const result = await userController.findAll();

      expect(result).toEqual(users);
    });
  });
});

먼저 1일차 회고에서 직면한 문제 중 아래와 같은 에러 문제가 있었다.

Nest can't resolve dependencies of the UserService (?, DataSource). Please make sure that the argument UserRepository at index [0] is available in the RootTestModule context.

위 오류는 npm run test 즉 테스트코드를 실행하면 발생하는 오류였는데 간단하게 해석하자면 테스팅 모듈에 UserRepository 디펜던시가 없어서 발생한 문제였다.

처음에는 해당 오류가 무슨 말인지 잘 이해하지 못했다. 하지만 타 블로그들을 검색하여 작성된 여러 유닛테스트 코드를 보면서 테스팅모듈에는 실제 서비스에서 사용한 프로바이더가 모두 적용되어 있어야 한다는 점을 알게 되었다.

그러므로 컨트롤러와 서비스를 대상으로 테스트코드를 작성하려면 그들의 프로바이더도 모두 테스팅모듈에 적용되어야 한다는 말이다. 이 시점에서는 서비스의 레포지토리 하나 조차 어떻게 테스팅모듈에 녹여내야 하는지 알지 못해 헤매는 상태였어서 "서비스의 findAll 함수에 대한 단위 테스트 작성하기"로 테스트코드 작성 목표를 변경했다. (컨트롤러 제외)

 

우선 기존 서비스 코드를 살펴보자

// user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { IUserServiceCreateUser } from './interface/user-service.interface';
import { User } from './user.entity';
import { Album } from '../album/album.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly dataSource: DataSource,
  ) {}

  // findAll
  async findAll(): Promise<User[]> {
    const users = await this.userRepository.createQueryBuilder('user')
      .select(['user.id', 'user.email', 'album.id', 'album.name'])
      .leftJoin('user.album', 'album')
      .getMany();
    return users
  }

서비스 코드에는 userRepository와 dataSource가 주입된 것을 확인할 수 있다. 이 부분이 테스팅모듈에 포함되어야 한다.
userService처럼 실제 Repository 클래스를 불러올 수도 있겠지만 그러면 실제로 테스트 시 DB에 접근하도록 코드를 작성해야 할 것이다. 현재 테스트를 위한 DB는 준비되지 않았으며, 현재는 테스트코드 작성 로직을 이해하는 것이 목적이므로 대안이 필요했다.
그렇다면 어떻게 repository를 테스트코드에 적용시킬 수 있을까 알아본 결과 아래와 같이 작성할 수 있었다.

 

const mockRepository = () => ({
  createQueryBuilder: jest.fn().mockReturnValue({
    select: jest.fn().mockReturnThis(),
    where: jest.fn().mockReturnThis(),
    leftJoin: jest.fn().mockReturnThis(),
    getOne: jest.fn().mockReturnThis(),
    getMany: jest.fn().mockReturnThis(),
  }),
});

type MockRepository<T = any> = Partial<Record<keyof Repository<T>, jest.Mock>>;

describe('UserService', () => {
  let userService: UserService;
  let userRepository: MockRepository<User>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: getRepositoryToken(User),
          useValue: mockRepository(),
        },
        {
          provide: DataSource, // DataSource는 트랜잭션 테스트에서 사용 예정
          useValue: jest.fn(),
        },
      ],
    }).compile();
    userService = module.get<UserService>(UserService);
    userRepository = module.get<MockRepository<User>>(getRepositoryToken(User));
  });

위와 같이 작성하는 의도는 모의 레포지토리를 생성하여 실제 리포지토리와 같이 작동하지는 않지만 모양새만 갖춘 레포지토리를 만든다는 취지이다.

먼저 MockRepository의 타입 설정을 살펴보자.
실제 TypeORM에서 제공하는 Repository의 모든 메서드를 jest.Mock 타입으로 매핑시켜서 모의 레포지토리를 위한 타입을 생성했다.
사실 위 코드에서 MockRepository 부분을 모두 Repository로 변경해도 테스트코드 작동에 별 이상은 없었다. 하지만 실제 데이터베이스와의 상호작용이 발생할 여지를 굳이 남겨두는 위험성을 가지고 가지 않기 위함이다.

그리고 모의 생성에 TypeORM의 getRepositoryToken 메서드를 활용한다는 것을 TypeORM 공식 문서에서 확인할 수 있었다.

마지막으로 mockRepository는 내가 userService에서 createQueryBuilder로 find를 하기 때문에 생성한 함수이다. 이 함수의 리턴값은 메서드가 담긴 객체이며 모의 레포지토리에서 createQueryBuilder를 손쉽게 다루기 위해 만들었다.

의문이 드는 지점이 발생했다. 사실상 mockRepository 함수로 레포지토리 내 메서드의 리턴값을 mockReturnThismockReturnValue로 조작했다. 그러므로 모의 레포지토리 대신 그냥 Repository<User>를 적용하더라도 실제 데이터베이스에 접근 안하지 않나? 라는 생각이 든다.

결론적으로는 userService의 작동에 문제 없도록 모의 레포지토리와 모의 데이터소스를 추가했다. 참고로 DataSource는 트랜잭션을 쓰는 엔드포인트에서 활용되었기에 존재함을 유의하자. 이번 findAll 테스트에서는 전혀 쓰이지 않으므로 단순히 jest.fn() 처리했다.

 

이제 진짜 테스트 코드 작성이다.

  describe('findAll', () => {
    const users: User[] = [
      {
        id: uuid.v1(),
        email: 'testemail1@gmail.com',
        password: 'abcd1234',
        createdAt: new Date('2023-01-01'),
        updatedAt: new Date('2023-01-01'),
        deletedAt: null,
        album: [],
      },
    ];

    it('should return an array of users data', async () => {
      jest
        .spyOn(userRepository.createQueryBuilder(), 'getMany')
        .mockResolvedValue(users);

      const result = await userService.findAll();
      expect(result).toEqual(users);
      expect(
        userRepository.createQueryBuilder().leftJoin)
        .toHaveBeenCalledTimes(1)
    });
  });

모의 유저데이터를 생성했다. (이름은 그냥 users라고 붙였다.)
typeORM의 createQueryBuilder를 써서 조회하면 마지막에 getOne, getMany 메서드로 최종 마무리 되는 것을 알 것이다. 이번 findAll 함수의 경우 getMany 메서드로 종결되므로 mockResolvedValue 메서드를 활용하여 리턴값이 users를 갖도록 지정했다.

이렇게 해서 실제 userService.findAll 함수를 실행하면 userRepository의 결과를 가져오는 것이 맞는지 테스트한 것으로 볼 수 있다.
추가적으로 leftJoin 메서드가 한 번 사용되었는가도 추가해서 테스트해봤는데 잘 통과되었다.

 

이번 시간을 통해 얻은 점

  1. 테스팅모듈 생성 시 의존성 주입된 인스턴스를 모두 포함시켜야 한다.
  2. 레포지토리를 테스팅모듈에서 다루는 법을 알게되었다.
  3. 유닛테스트에서는 하나의 기능의 정상 작동을 테스트하기 위해 나머지 기능의 대한 결과 또는 변수는 잘 획득되었다는 가정을 한다. 이 때 mockResolvedValue와 같은 메서드를 통해 필요한 값을 다이렉트로 제공할 수 있다.

 

참조블로그
https://velog.io/@hkja0111/NestJS-11-Unit-Test-QueryBuilder

728x90
728x90