Nest + Jest unit test (3) mocking을 활용한 unit test 작성
앞서 구성한 mocking을 활용하여 실제 테스트 코드를 작성해나가면서 직면할 수 있는 문제를 살펴보고 하나씩 해결해나가면서 테스트에 익숙해져봅시다.
1. mockResolvedValue(promise의 반환), mockReturnValue(일반 반환)
특정 메서드의 반환 값을 조작할 수 있습니다.
테스트 해보고 싶은 유닛은 createAccount 로직 중 기존 DB에 email이 존재하면 {ok : false...}를 반환하는 이 동작을 테스팅해본다고 가정합시다.
async createAccount({ email, password, role }: CreateAccountInput): Promise<{ ok: boolean; error?: string }> {
try {
// is it new user?
const exists = await this.users.findOne({ email });
if (exists) {
return { ok: false, error: 'There is a user with that email already' };
}
... 중략
작성한 mocking을 이용하여 findOne이 반환하는 값을 직접 지정하여 실제 DB에 쿼리를 날리지 않고 테스트를 진행할 수 있습니다.
describe('createAccount', () => {
it('should fail if user exists', async () => {
// 반환값을 속임. 실제 DB를 찾지 않도록하기 위함
userRepository.findOne.mockResolvedValue({ id: 1, email: 'lalalal' });
// 무언가 생성하면 앞서 설정한 반환값인 { id: 1, email: 'lalalal' } 반환. 기존 유저가 존재하므로 에러가 떠야 함
const result = await service.createAccount({
email: '',
password: '',
role: 0,
});
expect(result).toMatchObject({ ok: false, error: 'There is a user with that email already' });
});
});
2. toHaveBeenCalledTimes, toHaveBeenCalledWith
해당 메서드가 몇 번 사용되었는지, 어떤 인자를 받았는지를 체킹할 수 있습니다.
만약 toHaveBeenCalled()를 사용한다면 해당 함수가 사용되었기만 한다면 통과합니다.
그러나 toHaveBeen... 메서드들은 측정되는 메서드는 반드시 mock된 것이거나 spy function이어야 합니다.
일반 함수를 expect한다면 Matcher error: received value must be a mock or spy function 가 발생합니다.
findOne 한 이후, 기존재하지 않아서 새롭게 user를 생성하고 저장하고, verification도 생성한 후 메일까지 보내는 로직을 테스트해보겠습니다.
async createAccount({ email, password, role }: CreateAccountInput): Promise<{ ok: boolean; error?: string }> {
try {
// is it new user?
const exists = await this.users.findOne({ email });
if (exists) {
return { ok: false, error: 'There is a user with that email already' };
}
// create user and save it(password will be hased by listener)
const createdUser = await this.users.save(this.users.create({ email, password, role }));
const createdVerification = await this.verification.save(this.verification.create({ user: createdUser }));
//TODO: send virification email to, email, code
this.mailService.sendVerificationEmail(createdUser.email, createdUser.email, createdVerification.code);
return { ok: true };
} catch (error) {
return { ok: false, error: `Can't create user error message : ${error.message}` };
}
}
테스트하고자 하는 코드에서 각 메서드들이 무엇을 반환하는지를 참고 하여 테스트코드를 아래와 같이 적절하게 작성할 수 있습니다. 이건 하나하나 설명하기보다 직접 작성하면서 테스트하는 방법을 익히는 게 좋습니다.
describe('UserService', () => {
beforeAll(async () => {
const modules = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepository() },
{ provide: getRepositoryToken(Verification), useValue: mockRepository() },
{ provide: JwtService, useValue: mockJwtService },
{ provide: MailService, useValue: mockMailService },
],
}).compile();
service = modules.get<UsersService>(UsersService);
emailService = modules.get<MailService>(MailService);
userRepository = modules.get(getRepositoryToken(User));
verificationRepository = modules.get(getRepositoryToken(Verification));
});
describe('createAccount', () => {
const createAccountArgs = {
email: '',
password: '',
role: 0,
};
it('should create a new user', async () => {
userRepository.findOne.mockResolvedValue(undefined);
userRepository.create.mockReturnValue(createAccountArgs); // service에서 가서 올려보면 create는 User를 반환한다고 써 있음. 그대로 해주자
userRepository.save.mockResolvedValue(createAccountArgs); // service에서 가서 올려보면 save는 Promise<User>를 반환한다고 써 있음. 그대로 해주자(resolvedvalue)
verificationRepository.create.mockReturnValue({ user: createAccountArgs }); // Verification 반환. 그러나 testing 편의에 따라 변형 가능
verificationRepository.save.mockResolvedValue({ code: 'code' }); // Promise<Verification> 반환. 그러나 testing 편의에 따라 변형 가능
const result = await service.createAccount(createAccountArgs);
// service에서 가서 올려보면 create는 User를 반환한다고 써 있음. 그대로 해주자
expect(userRepository.create).toHaveBeenCalledTimes(1); // 함수가 1번 불릴 것으로 예상함 (mockRepository를 공유하지 않아야 함)
expect(userRepository.create).toHaveBeenCalledWith(createAccountArgs); // create 메서드일 때 들어올 인자 체킹
// service에서 가서 올려보면 save는 Promise<User>를 반환한다고 써 있음.어쨌거나 User 객체를 반환해주면 됨
expect(userRepository.save).toHaveBeenCalledTimes(1); // 함수가 1번 불릴 것으로 예상함 (mockRepository를 공유하지 않아야 함)
expect(userRepository.save).toHaveBeenCalledWith(createAccountArgs);
expect(verificationRepository.create).toHaveBeenCalledTimes(1);
expect(verificationRepository.create).toHaveBeenCalledWith({ user: createAccountArgs });
expect(verificationRepository.save).toHaveBeenCalledTimes(1);
expect(verificationRepository.save).toHaveBeenCalledWith({ user: createAccountArgs });
expect(emailService.sendVerificationEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendVerificationEmail).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(String),
); // to, email, code가 필요함. 여기서는 String 타입인지만 체킹하자
// 최종적으로 result는 아래와 같이 되어야 함
expect(result).toEqual({ ok: true });
});
});
});
3. beforeAll, beforEach 조심
아래를 테스트해보려고 하였습니다.
async login({ email, password }: LoginInput): Promise<{ ok: boolean; error?: string; token?: string }> {
try {
// fine the user with email
const user = await this.users.findOne({ email }, { select: ['id', 'password'] });
if (!user) {
return { ok: false, error: 'user not found' };
}
그래서 아래와 같이 테스트를 진행했으나 toHaveBeenCalledTimes에서 4번이 뜹니다..
이유는 앞서 beforeAll로 모듈을 생성하였고, 테스트 전에 존재하는 findOne이 모두 카운팅되었기 때문입니다.
describe('login', () => {
const loginArgs = { email: 'test@email.com', password: '1234' };
it('should fail if user does not exist', async () => {
userRepository.findOne.mockResolvedValue(null); // 유저 찾기가 실패해야함. null을 return하도록
const result = await service.login(loginArgs);
expect(userRepository.findOne).toHaveBeenCalledTimes(1); // 4번이 뜬다.. 왜?
expect(userRepository.findOne).toHaveBeenCalledWith(expect.any(Object), expect.any(Object));
expect(result).toEqual({ ok: false, error: 'user not found' });
});
});
이런 문제는 아래와 같이 모듈을 beforeAll이 아니라 beforeEach로 구성해줌으로서 해결할 수 있습니다.
일반적으로 유닛 테스팅에서는 beforeEach를 사용하게 됩니다.
beforeEach(async () => {
const modules = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepository() },
...중략
});
4. entity 메서드를 테스트하고 싶다면
user entity의 checkPassword라는 메서드를 달아준 적이 있는데 이를 테스트하고 싶습니다.
async login({ email, password }: LoginInput): Promise<{ ok: boolean; error?: string; token?: string }> {
try {
// fine the user with email
const user = await this.users.findOne({ email }, { select: ['id', 'password'] });
if (!user) {
return { ok: false, error: 'user not found' };
}
// check password right
const passwordCorrect = await user.checkPassword(password);
if (!passwordCorrect) {
return { ok: false, error: 'wrong password' };
}
이 경우는 반환되는 객체에 아래와 같이 직접 메서드를 jest.fn()을 통해 특정한 값을 리턴하도록 만들면 됩니다.
it('should fail if the password is wrong', async () => {
const mockedUser = { id: 1, checkPassword: jest.fn(() => Promise.resolve(false)) }; // entity 내부에 정의된 메서드를 쓸 것이므로
userRepository.findOne.mockResolvedValue(mockedUser);
const result = await service.login(loginArgs);
expect(result).toEqual({ ok: false, error: 'wrong password' });
});