Carryduo/Jest

Carryduo | Jest를 이용한 Unit/E2E 테스트 (NestJS)

차가운에스프레소 2023. 2. 28. 17:30

1. 개요 | 테스트 코드의 개념

- 테스트코드란 어플리케이션이 의도한대로 동작하는지 시험하기 위한 코드라고 생각한다.

- 어플리케이션은 일반적으로 여러 모듈, 그리고 모듈을 구성하는 여러 함수들로 구성되어 있기 때문에, 테스트코드도 테스트 범위에 따라 다음과 같이 일반적으로 종류가 나뉜다.

1. Unit Test
- 모듈을 구성하고 있는 단위 기능들에 대한 테스트

2. Integration Test
- 어플리케이션을 구성하는 여러 모듈 간 상호작용에 대한 테스트

3. E2E Test
- End to End Test로 사용자의 관점에서 어플리케이션이 의도대로(시나리오대로) 작동하는지에 대한 테스트

c.f) Intergration Test vs E2E Test
- Integration Test와 E2E Test는 코드상으로는 그 형태가 매우 유사하다고 생각한다.
- 두 테스트가 구분되는 가장 큰 차이는 테스트에 대한 관점이라고 생각한다.
- Integration Test는 개발의 관점에서 모듈 간 상호작용을 테스트하는 것에 초점을 둔다.
- E2E Test는 실제 사용자, 비즈니스의 관점에서 어플리케이션이 의도한대로 작동하는지 테스트하는 것에 초점을 둔다.

2. 테스트 코드에 대한 생각

- 아직 TDD에 입각해서 프로젝트를 진행해본 적은 없으나, 두 차례의 팀 프로젝트를 진행하면서 테스트 코드의 중요성을 느낀 순간이 있었다.

- 테스트 코드가 중요하다고 느끼는 포인트는 다음과 같다.

발단
- 테스트 코드에 대해 무지했을 때는 항상 API Client와 console.log를 이용해서 코드가 내 의도대로 작동하는 지를 확인했었다.

문제
- API Client로 확인하는 것은 결국 사람이 직접 일일이 확인하는 것과 동일하다.
- 이에, 프로젝트가 확장됨에 따라, API Client로 모든 코드를 테스트하는 것은 매우 어려운 일이며, 부정확성이 높아질 수 밖에 없다.

테스트 코드와 TDD에 대한 생각
- TDD까진 아니더라도, 테스트가 필수적인 모듈과 기능에 대한 테스트코드가 있더라면, 일일이 API Clinet로 확인할 필요가 없을 것이다.
- 즉, 디버깅 요소를 파악하기 위한 시간이 기하급수적으로 줄어들 수 있을 것이다.
- 요컨대, 개발 안정성, 유지보수의 용이성, 디버깅 시간 축소를 위해 테스트코드가 필요하다고 체감했다.

3. NestJS와 테스트코드

- NestJS는 Jest를 테스트 코드의 기본 프레임워크로 권장한다.

- 실제로 NestJS로 프로젝트를 생성할 시에, Jest가 함께 주입되는 것을 확인할 수 있다.

$ nest new project-name // name = 프로젝트의 이름
nest g mo 000 // 000이란 폴더에 module 생성
nest g co 000 // controller 생성
nest g service 000 // service 생성

- 한편, nest에서는 생성되는 테스트 파일은 일반적인 jest의 구조와는 다르다. 해당 파일은 jest가 아니라 @nestjs/testing에 기반하기 때문이다.

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

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)
        })
    })

 

@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();
  });
});

- NestJS에서 기본적인 jest가 아니라 jest에 기반한 @nestjs/testing을 테스트 프레임워크로 사용하는 이유는 다음과 같다고 이해된다.

기본적인 jest를 활용한 테스트는 단위 메소드들에 대한 테스트는 가능하지만, jest만으로는 nestJS라는 프레임워크의 특징을 고려한 테스트는 불가능하기 때문이다.
구체적으로, NestJS는 기본적으로 프로젝트의 단위 기능들이 모듈/캡슐화 되어 있어, 기능 간, 기능 내 계층 간 역할과 책임이 명확하게 구분되어 있다는 특징을 가지고 있다. jest만으로는 이 모듈 간 역할과 책임, 계층 간 의존성을 테스트하기 어렵다는 것이다.
이와 같은 이유에서 NestJS는 jest가 아니라 jest에 기반을 둔 @nestjs/testing를 제공하는 것이라 이해된다.

4. NestJS에서 JEST로 유닛 테스트

- 유닛테스트는 모듈을 구성하는 단위 기능들에 대한 테스트이다.

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

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

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

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

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

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,
    );
  });

4-1. mocking

- mocking이란 "모조품"이란 뜻으로, 유닛 테스트에서 말하는 mocking이라는 것은 테스트하고자 하는 환경이 의존하고 있는 function이나 class를 기존의 로직과 결과값이 아니라 다른 로직과 결과값으로 대체하는 것을 의미한다.

- 예컨대, 다음과 같은 상황에서 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는 테스트하고자 하는 맥락에 맞춰 모킹해주어야 한다.


4-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할 수 있다.

 

* 유의사항

- jest.spyOn(method).mockImpletation(function)은 mocking으로 활용될 수 있으나, method의 리턴 타입을 준수해야한다는 특징이 있다.
- 리턴 타입을 무시하고 mocking을 하고자 한다면, 다음과 같은 jest.fn()을 활용할 수 있다.

- 이는 repository에 있는 createSelectOption을 jest.fn()이라는 가짜 함수로 바꾸는 것과 같다.

    repository.createSelectOption = jest.fn().mockImplementation((data) => {
      return data;
    });

4-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 공식문서를 참고하면 좋다.


5. NestJS에서 Jest로 E2E 테스트

5-1. 개요: Carryduo에서 e2e 테스트의 목적

- e2e테스트는 사용자 관점에서 어플리케이션이 특정 요청을 의도한 대로 응답해주는 지를 테스트하는 것이다.

- 필자는 Carryduo 프로젝트에서 Unit Test로 테스트가 어려운 모듈 간 상호작용에 따른 응답을 테스트하기 위한 목적으로 e2e 테스트를 실행했다.

- 즉, 파일들의 이름은 e2e지만 필자의 테스트 코드에 대한 이해에 따르면, Integration Test를 진행한 것이다.

- 한편, 소셜로그인과 같이 프론트와 상호작용이 필수적인 기능의 경우에는 임의의 값을 테스트 이전에 생성하는 것으로 대체했다.


5-2. NestJS에서 E2E 테스트 개요

- NestJS에서는 프로젝트 생성 시 unit 테스트와 함께 E2E 테스트용 파일도 함께 생성해준다.

- E2E 테스트에는 supertest 패키지가 이용되며, 기본적인 생김새는 다음과 같다.

- 유의할 점은 E2E 테스트 전에 main.ts에서 app에 주입시킨 사항들을 추가해줘야 한다는 것이다.

- 코드의 형태가 보여주듯, app이라는 변수에 주입되는 것은 AppModule뿐이기 떄문이다.

- 이에, 실제 어플리케이션과 동일한 환경을 조성하기 위해서, main.ts에서 app에 주입한 사항들도 추가해주어야 한다.

import { Test, TestingModule } from '@nestjs/testing';
import { ClassSerializerInterceptor, INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from '../src/common/exception/http-exception.filter';

describe('AppController (e2e)', () => {
  let app: INestApplication;
  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

// main.ts에서 app에 주입시킨 사항들 추가.
    app = moduleFixture.createNestApplication();
    app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true, // 아무 decorator 도 없는 어떤 property의 object를 거름
        forbidNonWhitelisted: true, // 잘못된 property의 리퀘스트 자체를 막아버림
        transform: true,
      }),
    );
    app.useGlobalFilters(new HttpExceptionFilter()); // httpException filter 등록

// app 실행
	await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('this is carryduo development server. cd test');
  });

  afterAll(async () => {
    await app.close();
  });
});

5-1. E2E Test 실행하기

- E2E 테스트는 기본적으로 실행하는 구조가 Unit 테스트와 유사하다. 이에 Unit Test의 expect에 대한 내용과 다음 예시 코드를 참고하면 좋을 듯 하다.

  it('/:version (GET)', async () => {
    const response = await request(app.getHttpServer()).get('/combination-stat/version');
    const body = response.body;
    const status = response.statusCode;
    expect(status).toBe(200);
    expect(body.version).toBe('13.3.');
  });

 

* 유의사항: beforeEach vs beforeAll

- E2E 테스트를 실행할 때, before~ 을 통해 테스트 환경을 세팅하고, after~를 통해 테스트 환경을 다시 정비한다.

- before~을 기준으로 설명하자면, beforeEach와 beforeAll은 다음과 같은 차이가 있다.

  • beforeEach: 각 테스트를 실행하기 전에 befreEach 이하 내용을 실행한다.
  • beforeAll: 해당 파일의 모든 테스트를 실행하기 전에 beforeAll 이하 내용을 한번 실행한다.

- 예컨대 목차 5에 있는 예시코드에서 beforeAll이 beforeEach로 바뀐다면, 파일 내에 작성된 각 테스트들을 실행하기 전에 app을 새로 생성되는 것이다.

- 따라서, beforeEach로 테스트 환경을 세팅할 경우, 테스트 속도가 느려지는 현상을 목격할 수 있었고, beforeAll로 이를 변경하자 속도가 개선되는 것을 확인할 수 있었다.

- 이에, e2e 테스트를 작성하면서 상황에 따라 beforeEach와 beforeAll을 잘 활용하면 좋을 것 같다.