현재 사내 프로젝트의 백엔드 프레임워크로 NestJS를 사용중이다. 지금까지 개발자 한명이 코드를 작성하였고, 구현에 급급하여 테스트코드없이 개발을 해왔으나, 현재 잠깐의 여유가 생겨 단위 테스트 코드를 작성하기로 결정하였다.
(Jest 공식문서를 참고하였다.)
Framework? - Jest
JavaScript를 지원하는 테스팅 프레임워크는 굉장히 다양하나, NestJS에서 기본으로 제공하는 라이브러리(Jest기반)가 있기에 해당 라이브러리를 사용하기로 하였다.
Testing Structure
먼저, 테스팅코드의 구조가 어떻게 구성되어 있는지 알아본 뒤, NestJS에서 실제로 어떻게 사용할 수 있는지 알아보자.
테스팅코드는 하나의 큰 테스트 스위트(test suite) 안에 여러개의 관련된 테스트 블럭(Test Case)들로 이루어지게 된다.
아래의 코드를 통해 자세히 알아보도록 하자.
//describe를 통해 test suite 생성
describe('Filter function', () => {
//test 혹은 it을 통해 test block 생성
test('it should filter by a search term (link)', () => {
const input = [
{ id: 1, url: 'https://www.url1.dev' },
{ id: 2, url: 'https://www.url2.dev' },
{ id: 3, url: 'https://www.link3.dev' },
];
const output = [{ id: 3, url: 'https://www.link3.dev' }];
//expect를 통해 테스트 실행(값 비교)
expect(filterByTerm(input, 'link')).toEqual(output);
expect(filterByTerm(input, 'LINK')).toEqual(output);
});
});
function filterByTerm(inputArr, searchTerm) {
return inputArr.filter(function (arrayElement) {
return arrayElement.url.match(searchTerm);
});
}
위의 코드에서 주의 깊게 볼 것은 describe, it, 그리고 expect 메소드이다.
1. describe(name, callbackFn)
테스트 스위트를 만드는 메소드이다.
여기에서 테스트 스위트란, 관련된 작은 테스트블록들의 집합이다. 즉, 테스트 스위트 안에 관련된 여러개의 테스트 블록이 존재하게 되는 것이다.
해당 메소드는 총 두개의 인자(첫 번째 인자로 name:string, 두 번째 인자로 callbackFn: Fn)를 받게 된다.
첫 번째 인자인 name 부분에 해당 테스트 스위트를 나타내는 string이 오게 되고, 테스트 블록들을 wrapping해주는 콜백함수가 두 번째 인자로 오게 된다.
2. it(name, CallBackFn, timeout)
실제 테스팅이 일어나게 되는 test block을 생성해주는 메소드이다.
describe와 동일한 두가지 인자와 추가적으로 세 번째 인자를 받게 된다.
첫 번째 인자에는 해당 테스트 블록을 나타내는 string, 두 번째 인자에는 실제 테스팅을 하는 콜백함수가 오게 된다.
그리고 세 번째 인자는 몇 milliseconds 이후에 해당 콜백 함수가 실행 될지 나타내는 인자이다.
참고로, it메소드는 test 메소드와 같은 역할을 하는 alias이다. ( 참고링크 )
3. expect(value)
결국, 테스트란 것은 내가 기대하는 값과 실제 출력 값을 비교하는 과정이 필요하다. 그리고 이 두 값을 비교하는 메소드를 matcher 라고 한다. expect는 다양한 항목의 유효성을 검사할 수 있는 matcher에 접근 할 수 있도록 도와주는 인터페이스이다.
우리는 여러가지 matcher 메소드를 이용하여 우리가 기대한 값과 실제 출력 값을 비교할 수 있다.
위의 코드에서는 toEqual matcher를 사용하여 값을 비교 하고 있다.
우리가 일반적으로 자주 사용하게 되는 matcher에 대해서는 아래를 참고하자.
matcher
일반적으로 자주 사용되는 matcher에 대해 간단히 알아보자.
1. toBe
toBe는 두 값을 비교하기 위해 Object.is(참고링크)를 사용한다.
따라서 기본형 데이터 타입에 대해서는 정확한 비교가 되지만
참조형 데이터 타입에 대해서는 우리가 원하는 방식대로 결과가 나오지 않는다.
test('two plus two is four', () => {
expect(2 + 2).toBe(4); // true
expect(window).toBe(window); // true
expect([]).toBe([]); // false
var test = { a: 1 };
expect(test).toBe({ a: 1 }); // false
expect(test).toBe(test); // true
});
2. toEqual
toEqual은 객체나 배열의 모든 필드를 재귀적으로 확인한다.
따라서, toBe에서는 비교가 되지 않았던, 배열이나 객체 역시 비교가 가능하게 된다.
test('about toEqual', () => {
expect([]).toEqual([]); // true
var test = { a: 1 };
expect(test).toEqual({ a: 1 }); // true
expect(test).toEqual(test); // true
});
3. toMatch
toMatch를 통해 정규식과 비교하여 문자열을 검사 할 수 있다.
test('there is no I in team', () => {
expect('team').not.toMatch(/I/);
});
test('but there is a "stop" in Christoph', () => {
expect('Christoph').toMatch(/stop/);
});
4. toThrow
toThrow를 통해 특정함수가 호출될 때 오류가 발생하는지를 테스트 할 수 있다.
function compileAndroidCode() {
throw new Error('you are using the wrong JDK');
}
test('compiling android goes as expected', () => {
expect(compileAndroidCode).toThrow();
expect(compileAndroidCode).toThrow(Error);
// 정확한 오류 메세지나 정규식을 사용할 수도 있습니다
expect(compileAndroidCode).toThrow('you are using the wrong JDK');
expect(compileAndroidCode).toThrow(/JDK/);
});
NestJS에서 실제 테스트 코드 작성하기
이제, 테스트 코드를 작성하기 위한 기본기는 익혔다. NestJS 프로젝트에서 직접 작성을 해보자.
아래의 내용은 NestJS에 대해 기본 지식을 알고 있음을 전제한다. ( 참고링크 )
1. Installation
패키지를 먼저 설치하자.
npm i --save-dev @nestjs/testing
2. 테스트 파일 생성
테스트 내용을 작성할 파일을 생성하자. 이때 파일명에는 spec 혹은 test라는 접미사가 반드시 포함되어야 한다.
auth.service.ts를 테스트 하고 싶을 때, Directory구조는 아래와 같다.
3. 테스트 파일 환경
먼저 기본적으로 Testing 코드를 작성하기 이전에, 테스트하고자 하는 모듈(AuthService)과 똑같은 환경을 만들어 줘야 한다.
만약, 똑같은 환경을 만들어 주지 못한다면, 아래와 같은 에러메시지를 보게 될 것이다.
Nest can't resolve dependencies of the UsersService (?, UserRepository, UserSessionRepository, ConfigService). Please make sure that the argument HttpService at index [0] is available in the RootTestModule context.
아래의 코드는 그 기본 환경을 만들어준 예시이다.
import { Test } from '@nestjs/testing';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
// mock 데이터
const mockUserService = {
create: jest.fn(),
getById: jest.fn(),
};
const mockJwtService = {
sign: jest.fn(),
};
//1. test suite 생성
describe('AuthService', () => {
let authService: AuthService;
//2. 테스팅 모듈 생성
//beforeEach 메소드는 테스트블록이 실행되기 직전에 매번 실행된다.(초기화 목적)
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
ConfigService,
{ provide: UsersService, useValue: mockUserService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
authService = moduleRef.get<AuthService>(AuthService);
});
describe('findAll', () => {
it('test', () => {
expect(1 + 1).toBe(2);
});
});
});
위 코드에서는 앞서 설명하지 않은, mock 데이터와 beforeEach, createTestingModule, compile과 같은 새로운 메소드가 나오지만 우선은 어떻게 테스트 할 수 있는 기본환경을 만들어 주는지부터 알아보자.
1. Test suite 생성: describe
우리가 테스트 하고자 하는 대상은 AuthService이다. 앞서 배운대로 describe를 이용해 AuthService에 대한 test suite를 생성해주자.
2. 테스팅 모듈 생성: createTestingModule, compile
우리는 테스트만를 할 수 있는 독립된 테스팅 모듈이 필요하다. 이 독립된 Testing Module을 생성하는 메소드가 Test 클래스 안에 있는 createTestingModule 메소드와 compile 메소드 이다.
createTestingModule의 모듈생성에 필요한 메타데이터를 인자로 전달하고, compile 메소드를 통해 일반적으로 NestJS에서 모듈을 만드는 방식으로 테스팅 모듈을 간단하게 생성 가능하다.
3. providers 등록
여기에서 provider란, 비지니스 로직을 담당하는 자바스크립트 Class를 의미하며, 이 provider들은 dependency로 다른 곳에 Inject가능하다. (물론, 이 글은 이러한 내용들에 대해 이미 알고 있는 것을 전제로 하지만, 헷갈린다면 링크를 참고하자.)
즉, 우리는 똑같은 환경을 맞춰 주기 위해, 실제 AuthService의 dependency들을 providers에 등록해주면 된다는 것이다. 위 코드를 보면, provider를 두가지 방식으로 등록해주고 있는 것을 볼 수 있다. 둘의 차이점은 무엇인지, 이렇게 하는 이유에 대해 알아보자.
- import한 클래스를 그대로 등록우리가 NestJS에서 일반적으로 dependency를 inject하는 방식이다.
해당 방식으로 작성시, provide와 useValue가 같음을 의미하게 된다.
//아래 두 providers는 같은 의미이다.
providers: [ UsersService ]
providers: [ { provide: UsersService, useValue: UsersService } ]
- provide와 useValue를 나누어 등록
이 방식은 말 그대로 provide와 useValue를 다르게 등록하기 위해 사용된다.
왜 이런 방식이 필요하게 되는 것일까? 바로 mockData를 이용하기 위함이다.
NestJS는 아래 그림처럼 기본적으로 rootModule 아래에 여러개의 모듈들이 등록되는 방식의 구조를 가지고 있다.
그렇기 때문에, 일반적으로 dependency를 등록 해줄 때에는 1번 방식으로 등록하여도 아무런 문제가 생기지 않는다.
(아래의 내용은 공식문서가 아닌, 여러 블로그와 내 경험으로 얻은 추측이다. 틀린 내용이 있을 수 있다. )
하지만, Testing Module은 아래와 같은 일반적인 Module 구조에서 벗어난 독립적인 모듈로 생성되게 된다.
그렇기 때문에, UsersService라는 provider을 dependency(depth: lv. 1)로 등록해주게 되면, 그 UsersService가 가지고 있는 dependency(depth: lv. 2)도 또 등록해줘야 하고, 그럼 그 dependency가 가지고 있는 다른 dependency(depth: lv. 3)... 이런 형식으로 거의 모든 dependency를 다 등록해줘야 하는 불상사가 생기게 된다. 이는 결코 우리가 원하는 방식이 아니다. 그렇기 때문에, depth lv. 1 수준까지만 import 해서 등록해주고, 그 이상의 depth가 필요하다면, mock 데이터(혹은 mock Module)을 생성하여, useValue로 등록해주는 것이다.
- mockData가 가지는 의미
그렇다면, 아래의 코드에서 mockData는 어떤 의미를 가지는 것 일까?
const mockUserService = { create: jest.fn(), getById: jest.fn(), }; const mockJwtService = { sign: jest.fn(), }; describe('AuthService', () => { let authService: AuthService; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ AuthService, ConfigService, { provide: UsersService, useValue: mockUserService }, { provide: JwtService, useValue: mockJwtService }, ], }).compile();
껍질은 UsersService를 쓰지만, 그 안의 내용은 위에서 선언한 mockUserService를 쓰겠다는 의미이다.
따라서, 만약, 테스트 코드에서 원래 UsersService에는 존재하지만, mock에는 없는 findAll() 이라는 메소드를 호출하게 되면, TypeError: userService.findAll is not a function 와 같은 Error 메시지를 보게 될 것이다.
즉, userService에서 create, getById 같은 메소드를 사용하고 싶다면, 위 코드처럼 직접 mockData로 작성 해줘야 한다는 것이다. (jest.fn()이 정확히 무엇인지는 링크를 참고하고, 현재는 mockFunction을 만들어주는 메소드란 정도만 알고 넘어가자.)
4. 테스트 코드 작성
자, 이제 기본 환경설정은 모두 끝났다. 진짜 테스트 코드를 작성해보자!
( 참고로, authService의 register는 입력받은 유저객체에서 password를 제외한 나머지 정보를 리턴하는 메소드이다. )
describe('AuthService', () => {
let authService: AuthService;
let userService: UsersService;
let user;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
AuthService,
ConfigService,
{ provide: UsersService, useValue: mockUserService },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
authService = moduleRef.get<AuthService>(AuthService);
userService = moduleRef.get<UsersService>(UsersService);
user = {
idx: 1,
id: 1,
name: 'test',
email: 'test@test.com',
password: '1234',
current_hashed_refresh_token: 'test either',
role: 2,
is_active: 1,
is_reset_pwd: 0,
created_dt: new Date(),
updatedAt: new Date(),
};
});
it('register User', async () => {
mockUserService.create.mockImplementationOnce(() => user);
const { password, ...expectedValue } = user;
expect(await authService.register(user)).toEqual(expectedValue);
});
});
드디어, 아까는 그냥 넘어갔던 jest.fn()이 어떻게 작동하는지, 어떻게 사용해야 하는지 알아보자.
1. jest.fn(implementation)
jest.fn()은 implementation 인자를 optional하게 받으며, 사용하지 않은 mock function을 리턴하게 된다. ( 참고링크 )
만약, implementation 없이 jest.fn() 과 같은 방식으로 사용된 경우, undefined 를 return 한다.
2. mock function의 methods
mock function은 다양한 methods를 가지고 있다. (전체 리스트는 링크를 참고하자. )
여기에서는 자주 사용되는 메소드 몇가지에 대해 알아보자.
- mockFn.mock.calls
mock function이 호출 될 당시의 arguments들을 array형식으로 리턴한다.
mockF('arg1', 'arg2')
mockF('arg3', 'arg4')
//mock.calls 은 [['arg1', 'arg2'],['arg3', 'arg4']] 이 된다.
- mockFn.mockImplementation(fn)
mockfunction이 실행 해야 할 function을 인자(fn)로 받아 설정한다.
* 참고: jest.fn(implementation)은 jest.fn().mockImplementation(implementation)의 shorthand 이다.
const mockFn = jest.fn().mockImplementation(scalar => 42 + scalar);
// or: jest.fn(scalar => 42 + scalar);
const a = mockFn(0);
const b = mockFn(1);
a === 42; // true
b === 43; // true
mockFn.mock.calls[0][0] === 0; // true
mockFn.mock.calls[1][0] === 1; // true
- mockFn.mockReturnValue(value)
mock function이 불릴 때 마다 return 할 value를 인자로 받아 설정한다.
const mock = jest.fn();
mock.mockReturnValue(42);
mock(); // 42
mock.mockReturnValue(43);
mock(); // 43
- mockFn.mockResolvedValue(value)
syntatic sugar forjest.fn().mockImplementation(()=>Promise.resolve(value));
async, await 구문에서 유용하게 사용할 수 있다.
//잘못된 방식. //mockUserService.create()을 실행하게 되면 pending상태의 promise가 리턴된다. it('async test', async () => { mockUserService.create.mockResolvedValue(34); expect(mockUserService.create()).toBe(34); }); //정상적인 작동방식 it('async test', async () => { mockUserService.create.mockResolvedValue(34); expect(await mockUserService.create()).toBe(34); });
- mockFn.mockRejectedValue(value)
Syntactic sugar function for jest.fn().mockImplementation(() => Promise.reject(value);
test('async test', async () => { const asyncMock = jest.fn().mockRejectedValue(new Error('Async error')); await asyncMock(); // throws "Async error" });
마무리
생전 처음 테스트 코드를 작성하면서, 정말 많은 문제들을 만났다.
사실 테스트 코드를 작성하는 것 자체는 어렵지 않았지만, 테스트코드를 작성하기 위한 환경을 조성하고, 효율적인 테스트코드를 작성하는 것이 정말 어려웠다.
특히, NestJS에서 dependency관련된 에러는 다 만나본 것 같다.
DI라는 개념이 익숙하지 않고 + 테스트 코드도 처음인 상황 에서 NestJS를 이용해서 테스트 코드를 작성하려고 하니 어쩔 수 없는 시행착오였겠지만 말이다.
하지만 삽질을 많이 한 덕분에, NestJs에 대해서, DI에 대해서, 그리고 테스트 코드에 대해서 보다 심도 깊게 이해할 수 있게 된 것같다.
다음에는 어떻게 하면 효율적으로 테스팅 코드를 작성 할 수 있는지 정리해보겠다.
'테스트코드' 카테고리의 다른 글
[Solved] Jest did not exit one second after the test run has completed. (0) | 2022.08.04 |
---|---|
테스트 DB와 연결하기(feat. Jest with NestJS) (0) | 2022.04.08 |
UnitTest - createQueryBuilder (chained method) mocking하기 (0) | 2022.04.07 |
Unit Test에서 독립성 유지하기(feat. NestJS with Jest) (0) | 2022.03.28 |