이러한 컨트롤러를 테스트한다고 생각해보자.
// auth.controller.ts
import { Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('sample')
async createSampleUser() {
return await this.authService.createSampleUser();
}
}
이 컨트롤러가 의존하는 서비스는 아래 코드이다.
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class AuthService {
constructor(@InjectRepository(User) private usersRepository: Repository<User>) {}
async createSampleUser() {
let arr = [];
for (let i = 1; i <= 5; i++) {
const temp = {
email: `test_emai_${i}@gmail.com`,
password: 'qwer1234',
};
arr.push(temp);
}
return await this.usersRepository.insert(arr);
}
}
TypeOrm의 리포지토리를 의존성으로 가지는 서비스라는 걸 알 수 있다.
다음엔 컨트롤러 파일을 nest cli로 만들 때에 같이 만들어진 테스트 파일을 보자.
// auth.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TestService } from './test.service';
describe('AuthService', () => {
let controller: AuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
}).compile();
controller = module.get<AuthController>(AuthController);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
이렇게 시작하면 바로 에러가 날 것이다. 왜냐하면 테스트로 만들어진 모듈에서 AuthService를 provide하지 않기 때문이다. 물론 provider에 AuthService를 추가해도 에러가 나는데, 이번엔 AuthService가 필요한 UserRepository 주입이 안 되기 때문이다.
여기서 필요한 게 실제 사용되는 객체를 가짜(Mock)로 바꾸어 사용하는 Mocking이다. 여러가지 방법이 있다.
{
provide: AuthService,
useClass: MockAuthService
}
이 방법은 안정적이다. 가짜를 제공하기에 의존성 문제도 없고 실제 클래스처럼 만들 수 있다.
// service는 module.get(AuthService)로 가져왔다고 가정한다.
const createSampleUserSpy = jest.spyOn(service, 'createSampleUser');
createSampleUserSpy.mockResolvedValue('spy')
const result = await service.createSampleUser() // spy
spy를 사용하면 실제 객체의 메소드가 실행되는 걸 가로채서 자기가 실행된다. 이 경우엔 실제 객체가 주입된다. 다만 해당하는 메소드가 가짜 함수로 실행될 뿐이다.
위의 두 방법을 사용해도 괜찮겠지만, 나는 Nestjs 공식문서에서 알려준 AutoMocking이 마음에 들었다.
https://docs.nestjs.com/fundamentals/testing#auto-mocking
해당 페이지의 예시코드를 보자.
import { ModuleMocker, MockFunctionMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global);
describe('CatsController', () => {
let controller: CatsController;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
})
.useMocker((token) => {
const results = ['test1', 'test2'];
if (token === CatsService) {
return { findAll: jest.fn().mockResolvedValue(results) };
}
if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
.compile();
controller = moduleRef.get(CatsController);
});
});
컨트롤러를 테스트하는데 provider가 하나도 없다. 그럼 CatController의 의존성은 어떻게 처리하는 걸까. 답은 바로 useMocker로 token 별로 주입하는 함수를 return한다. 내가 이해한 바로는 위에서는 CatsService를 토큰으로 하는 의존성 주입은 메소드가 jest.fn()인 걸 반환하도록 되어있다.
그 다음에 typeof token === 'function'은 함수(클래스 포함)가 토큰인 경우에 자동으로 mocking화 시키는 거 같다. 그냥 원래 주입하려는 것을 모킹해서 가짜로 주입한다, 정도로만 이해해도 될 거 같다.
token 자체가 함수라면 제공하는 가짜함수를 그 함수 자체를 이용해서 만드는 느낌이다.
TypeOrm에서 제공하는 함수를 보면 getRepositoryToken이라는 함수가 있다. 엔티티를 인자로 받으면 그 엔티티로 제공하는 리포지토리의 토큰을 알 수 있다. 즉, @InjectRepository(entity)가 반환하는 @Inject(token)를 생각했을 때 그 token을 알 수 있으므로, useMocker에서 token이 getRepositoryToken(entity)일 때, Mock을 반환해주면 된다.
.useMocker((token) => {
if (token === getRepositoryToken(User)) {
return {
findOneBy: jest.fn(),
...
}
}
})
useMocker 자체가 autoMocking은 아니다. providers에 Mock를 제공하도록 적었던 수동 mocking 방법을 다르게 쓴 것으로 보인다.
autoMocking이란 ModuleMocker로 import한 모든 모듈(다른 파일에서 내보낸 것)을 mocking하고 토큰이 함수일 때, 그 만들어낸 mocking을 주입하는 것이라고 보인다.