Carryduo | JEST 유닛테스트(nestJS)

유현준·2022년 11월 4일
0

1. nestJS 테스트 개요:@nestjs/testing인 이유

  1. nest에서는 테스트를 위한 기본 프레임워크로 jest를 활용한다.

  2. 다음과 같이 nest에 기반하여 프로젝트를 생성하면, jest에 기반한 테스트 파일들이 자동으로 생성된다.

    $ nest new project-name // name = 프로젝트의 이름
    nest g mo 000 // 000이란 폴더에 module 생성
    nest g co 000 // controller 생성
    nest g service 000 // service 생성 
  3. 한편, nest에서는 생성되는 테스트 파일은 일반적인 jest의 구조와는 다르다. 해당 파일은 jest가 아니라 @nestjs/testing에 기반하기 때문이다.

  4. @nestjs/testing에 기반한 테스트 파일과 jest에 기반한 테스트 파일은 다음과 같은 차이가 있다.

    @nestjs/testing 테스트 파일 예시
    example.service.spec.ts
    
    import { Test, TestingModule } from '@nestjs/testing';
    import { exampleService } from './example.service';
    
    describe('example', () => {
      let service: SubscriptionService;
    
      beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
          providers: [exampleService],
        }).compile();
    
        service = module.get<exampleService>(exampleService);
      });
    
      it('should be defined', () => {
        expect(service).toBeDefined();
      });
    });
    jest 테스트 파일 예시
    example.service.spec.ts
    const httpMocks = require('node-mocks-http')
    const exampleController = require('../controllers/example.controller')
    const exampleService = require('../services/example.service')
    const moment = require('moment')
    
    beforeEach(() => {
        req = httpMocks.createRequest()
        res = httpMocks.createResponse()
        next = jest.fn()
    })
    
    describe('example', () => {
        describe('example', () => {
            it('example', async () => {
                await exampleController.getGroup(req, res, next)
                expect(next).toBeCalledWith(err)
            })
        })
  5. nestJS에서 기본적인 jest가 아니라 jest에 기반한 @nestjs/testing을 테스트 프레임워크로 사용하는 이유는 다음과 같다고 이해된다.

    기본적인 jest를 활용한 테스트는 단위 메소드들에 대한 테스트는 가능하지만, jest만으로는 nestJS라는 프레임워크의 특징을 고려한 테스트는 불가능하기 때문이다.

    구체적으로, nestJS는 기본적으로 프로젝트의 단위 기능들이 모듈/캡슐화 되어 있어, 기능 간, 기능 내 계층 간 역할과 책임이 명확하게 구분되어 있다는 특징을 가지고 있다. jest만으로는 이 모듈 간 역할과 책임, 계층 간 의존성을 테스트하기 어렵다는 것이다.

    이와 같은 이유에서 nestJS는 jest가 아니라 jest에 기반을 둔 @nestjs/testing를 제공하는 것이라 이해된다.

2. 유닛 테스트 환경에서 모듈 주입

  1. 위에서 설명한 것처럼, nestJS에서 유닛 테스트를 진행하기 위해서는 먼저 유닛 테스트 환경에서 테스트하고자 하는 모듈을 주입하는 것부터 해야한다. 구체적으로, 모듈이 의존하고 있는 다른 모듈들을 유닛 테스트 환경에 맞춰서 주입해야한다.

  2. 예컨대, controller에 대한 유닛 테스트를 진행한다고 하면, service에 대한 의존성 주입을 해주어야 하고, service 의존성 주입을 위해, serivce가 의존하고 있는 repository나 DB에 대한 주입을 해주어야 하는 것이다.

  3. 이 떄, 의존성 주입해주는 모듈들은 테스트 환경에 따라서 적절히 모킹해주는 것이 필요하다.

    유닛 테스트는 단위 메소드의 순수한 비즈니스 로직을 테스트하는 것이다. 예컨대, controller가 의존하고 있는 serivce의 로직은 controller에 대한 유닛 테스트에 영향을 미쳐서는 안되는 것이기 때문이다.

  4. 테스트 환경에서 모듈을 주입하는 예시 코드는 다음과 같다.

combination-stat.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CombinationStatRepository } from '../combination-stat.repository';
import { CombinationStatService } from '../combination-stat.service';
import { CombinationStatEntity } from '../entities/combination-stat.entity';
import * as testData from './data/combination-stat.test.data';

const mockRepository = () => {
  createQueryBuilder: jest.fn();
};

describe('CombinationStatController', () => {
  let service: CombinationStatService;
  let repository: CombinationStatRepository;
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        CombinationStatService,
        CombinationStatRepository,
        {
          provide: getRepositoryToken(CombinationStatEntity),
          useValue: mockRepository,
        },
      ],
    }).compile();
    service = module.get<CombinationStatService>(CombinationStatService);
    repository = module.get<CombinationStatRepository>(
      CombinationStatRepository,
    );
  });

2-1: mocking

  1. mocking이란 "모조품"이란 뜻으로, 유닛 테스트에서 말하는 mocking이라는 것은 테스트하고자 하는 환경이 의존하고 있는 function이나 class를 기존의 로직과 결과값이 아니라 다른 로직과 결과값으로 대체하는 것을 의미한다.
  2. 예컨대, 다음과 같은 상황에서 mocking이 요구 될 수 있을 것이다.
테스트하고자 하는 메소드: userController - signUser()
의존하고 있는 모듈 및 메소드: userService - checkUser(), createAndFindUser(), 
  jwt.sign()

-- userController --
async signUser(user) => {
	let data;
	data = await userService.findUser(user.userId)

  if (data === null){
      data = await userService.createAndFindUser(user)
  }
return data
}


-- userService --
async checkUser(userId) => {
 	return await userRepository.find(userId) 
}

async createAndFindUser(userId) => {
  const data = await userRepository.create(user)
  
  const user = await userRepository.find(data.userId)
  
  return {
    nickname: user.nickname,
    token: await jwt.sign({sub: 'sample'}, {secretKey: secretkey})
  }
} 
  • userController의 signUser에 대한 유닛 테스트는 signUser의 순수한 로직만을 테스트해야한다. 즉, 위 작성된 내용 중 checkUser, createAndFindUser의 로직과 return 값은 signUser를 테스트하는데 영향을 미쳐서는 안된다. 그러므로 checkUser와 createAndFindUser는 테스트하고자 하는 맥락에 맞춰 모킹해주어야 한다.

2-2. @nestjs/testing에서 mocking 하는 방법

1) 계층 + 메소드 자체를 mocking 하기

  • 아래 코드는 계층 + 메소드 자체를 mocking하는 예시 코드이다. 예시 코드의 상황은 다음과 같다.
    | 유닛 테스트 대상 계층: champService
    | chamService에 주입되는 의존성 계층: champRepository
    | champRepository에 주입되는 DB 테이블: champEntity
- (3) repository 계층의 각 메소드의 로직/return 값 모킹
class MockChampRepository {
  champIds = ['1', '2', '3', '4', '5'];
  preferChamp = [
    { preferChamp: '1', user: 'kim' },
    { preferChamp: '1', user: 'lee' },
    { preferChamp: '2', user: 'park' },
  ];
  getChampList() {
    return champList;
  }
  findPreferChampUsers(champId) {
    for (const p of this.preferChamp) {
      if (p.preferChamp === champId) {
        return preferChampUserList;
      } else {
        return [];
      }
    }
  }

  getTargetChampion(champId) {
    if (!this.champIds.includes(champId)) {
      throw new HttpException(
        '해당하는 챔피언 정보가 없습니다.',
        HttpStatus.BAD_REQUEST,
      );
    } else {
      return testData.champInfo;
    }
  }

  getChampSpell(champId) {
    return champSpell;
  }
}
describe('ChampService', () => {
  let service: ChampService;
  let repository: ChampRepository;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ChampService,
        
        - (1) repository 계층 mocking, 계층 안에서 제공할 메소드들은 위 MockChampRepository class로 모킹
        { provide: ChampRepository, useClass: MockChampRepository },
        
      	- (2) repository 계층에 주입되는 DB 테이블을 임의로 mocking(with TypeORM)
        { provide: getRepositoryToken(ChampEntity), useClass: MockRepository },
      ],
    }).compile();

    service = module.get<ChampService>(ChampService);
    repository = module.get<ChampRepository>(ChampRepository);
  });
  • (1), (3)의 맥락을 통해서 계층 자체를 테스트 환경에서 임의적으로 모킹할 수 있다.

2) 메소드만 mocking 하기

  • 예시 코드 상황은 다음과 같다.
    | 유닛 테스트 대상 계층: adminService
    | adminService에 주입되는 의존성 계층: adminRepository
    | adminRepository에 주입되는 DB 테이블: userEntity
describe('AdminService', () => {
  const mockRepository = () => {
    createQueryBuilder: jest.fn();
  };
  class MockChache {}
  let service: AdminService;
  let adminRepository: AdminRepository;
  
  - (3) 프로젝트에서 사용하는 외부 패키지인 JwtService 모킹(1) 
  let jwtService: JwtService;
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AdminService,
        
        - (1) repository 계층 자체는 그대로 주입. 프로젝트의 adminRepository 계층에 작성된 내용이 그대로 반영됨.
        AdminRepository,
        
        - (3) 프로젝트에서 사용하는 외부 패키지인 JwtService 모킹(2): useValue 활용하여
      JwtService의 하위 메소드인 signAsync의 return 값을 테스트 환경에 맞춰서 모킹
        {
          provide: JwtService,
          useValue: {
            signAsync: (payload, option) => {
              return 'sample token';
            },
          },
        },
        {
          provide: getRepositoryToken(UserEntity),
          useValue: mockRepository,
        },
      ],
    }).compile();

    service = module.get<AdminService>(AdminService);
    adminRepository = module.get<AdminRepository>(AdminRepository);
  });

  const loginData = {
    socialId: '1',
    social: 'kakao',
    nickname: 'user1',
    profileImg: 'user1-profileImg',
  };
  const loginResult = {
    id: 'sampleId',
    nickname: 'user1',
    token: 'sample token',
  };

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

  it('kakaoLogin test: 카카오 로그인 시, 이미 가입 된 유저인 경우 id, nickname, token을 return하는가?', async () => {
    
    (2) adminRepository의 checkUser메소드만 mockImplementation을 활용하여 return 값 mocking
    jest.spyOn(adminRepository, 'checkUser').mockImplementation(
      (data) =>
        new Promise((resolve) => {
          resolve({ userId: 'sampleId', nickname: data.nickname });
        }),
    );
    expect(await service.kakaoLogin(loginData)).toEqual(loginResult);
  });
  • (1), (2)의 맥락을 통해서 계층 내 특정 메소드만 mocking할 수 있다.
    - 위 예시 코드에서는 jest.spyOn()과 mockImplementation이 활용되었으나, jest.fn(), jest.mock() 등 jest에서 제공하는 mock 관련 메소드를 활용할 수 있다.
  • (3)과 같이 jwtService, axios 등 프로젝트에서 사용되는 외부 패키지들 또한 mocking할 수 있다.

3. 유닛테스트 실행

  • 테스트 실행은 expect()를 활용할 수 있다. 아래는 예시코드이다.

일반적인 테스트 구조
  it('kakaoLogin test: 카카오 로그인 시, 이미 가입 된 유저인 경우 id, nickname, token을 return하는가?', async () => {
    jest.spyOn(adminRepository, 'checkUser').mockImplementation(
      (data) =>
        new Promise((resolve) => {
          resolve({ userId: 'sampleId', nickname: data.nickname });
        }),
    );
    expect(await service.kakaoLogin(loginData)).toEqual(loginResult);
  });

예외처리 로직 테스트 구조
  it('kakaoLogin test: 카카오 로그인 시, 이미 가입 된 유저인 경우 id, nickname, token을 return하는가?', async () => {
    jest.spyOn(adminRepository, 'checkUser').mockImplementation(
      (data) =>
        new Promise((resolve) => {
          resolve({ userId: 'sampleId', nickname: data.nickname });
        }),
    );
    try {
      await service.kakaoLogin(loginData)
    } catch(error){
     expect(error.message).toEqaul('예시 에러') 
    }
  });
  • expect(a).toEqaul(b)의 구조로 단위 유닛테스트는 실행되는데, a의 결과값이 b와 동일한지를 테스트하는 것을 의미한다.
  • 이 때, toEqaul()은 목적에 따라서 toBe(), toFaulsy() 등 다른 메소드들이 활용될 수 있는데, 이는 필요에 따라서 jest 공식문서를 참고하면 좋다.

결론

  • 유닛 테스트를 실행할 때는 테스트하고자 하는 계층, 테스트하고자하는 메소드만을 순수하게 테스트할 수 있는 환경을 조성하는 것이 중요하다.
  • 그런 환경을 조성하는 데 mocking은 유닛 테스트에서 매우 필요한 작업이라고 할 수 있다. 위 내용이 nestJS 환경에서 유닛 테스트를 진행하는데 도움이 되길 바란다.
profile
차가운에스프레소의 개발블로그입니다. (22.03. ~ 22.12.)

0개의 댓글