Node, Nest, Deno/🦁 Nest - Series

Nest + Jest unit test (3) mocking을 활용한 unit test 작성

DarrenKwonDev 2020. 11. 28. 03:27

앞서 구성한 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' });
});