nest에서는 테스트를 위한 기본 프레임워크로 jest
를 활용한다.
다음과 같이 nest에 기반하여 프로젝트를 생성하면, 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에 기반한 테스트 파일은 다음과 같은 차이가 있다.
@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)
})
})
nestJS에서 기본적인 jest가 아니라 jest에 기반한 @nestjs/testing을 테스트 프레임워크로 사용하는 이유는 다음과 같다고 이해된다.
기본적인 jest를 활용한 테스트는 단위 메소드들에 대한 테스트는 가능하지만, jest만으로는 nestJS라는 프레임워크의 특징을 고려한 테스트는 불가능하기 때문이다.
구체적으로, nestJS는 기본적으로 프로젝트의 단위 기능들이 모듈/캡슐화 되어 있어, 기능 간, 기능 내 계층 간 역할과 책임이 명확하게 구분되어 있다는 특징을 가지고 있다. jest만으로는 이 모듈 간 역할과 책임, 계층 간 의존성을 테스트하기 어렵다는 것이다.
이와 같은 이유에서 nestJS는 jest가 아니라 jest에 기반을 둔 @nestjs/testing를 제공하는 것이라 이해된다.
위에서 설명한 것처럼, 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,
);
});
테스트하고자 하는 메소드: 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})
}
}
- (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);
});
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);
});
일반적인 테스트 구조
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('예시 에러')
}
});