[TDD] 3. 외부 라이브러리 테스트

아홉번째태양·2023년 6월 20일
0

TDD

목록 보기
4/4

1. Mocking이란?

외부 라이브러리를 테스트할 때는 mocking을 해야한다. 사전적으로 mocking이란 "따라하는 것"이다. 그리고 테스트에서의 mocking도 같은 의미이며, 이때 특정 객체 혹은 함수를 마치 실제로 호출한것처럼 따라하기 위해 사용한다.

유닛 테스트는 작은 범위의 로직이 의도한대로 동작하는지 체크하기 위한 테스트다. 그런데 외부 의존성의 동작 성공 여부에 따라 테스트하려는 대상의 동작을 결과가 달라진다면 테스트를 하기 매우 까다로워진다. 따라서, 외부 의존성은 mocking하여 어느정도 의도한 결과를 항상 내놓도록 만듬으로서 유닛 테스트를 항상 같은 조건에서 시행할 수 있게 된다.

2. 의존성이 주입 Mocking

우선 회원가입 기능 테스트 진행상황을 다시 보자.

[보류] 회원가입 폼 유효성 검사
[✅] 회원 유형 확인
[] 회원 중복 검사
[] 비밀번호 암호화
[] 회원 데이터 생성

다음번 유닛 테스트는 회원 중복 검사인데, 이를 위해서는 데이터베이스에 특정값을 가진 데이터가 존재하는지 질의를 해야한다. 하지만 데이터베이스도 엄연히 외부 의존성이기 때문에 여기서는 mocking을 하여 진행한다.

필요로하는 형태에 따라 방식은 조금씩 달라지겠지만, 일반적으로 데이터베이스에 쿼리를 보내는 메소드들을 jest.fn으로 mocking하여 원하는 결과를 가져오도록 설정할 수 있다.

그리고 데이터베이스 질의를 위해 Prisma를 사용하는데, Prisma를 Nest IoC에 의존성을 주입하여 사용하기 때문에 테스트 애플리케이션을 생성할 때 이에 대해서 따로 세팅을 해야 한다.

// users/users.service.spec.ts
describe('UsersService', () => {
  let service: UsersService;

  interface Select {
    where: {
      email: string;
    };
  }

  const testUser = {
    userId: 1,
    email: 'test1@delivery.com',
    name: 'Test Kim',
    password: 'qwe1234',
    type: 'customer',
  }

  const mockPrismaService = {
    user: {
      findUnique: jest.fn((select: Select) => {
        return select.where.email === testUser.email ? testUser : null;
      }),
    }
  }

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        PrismaService,
      ],
    })
      .overrideProvider(PrismaService)
      .useValue(mockPrismaService)
      .compile();

    service = module.get<UsersService>(UsersService);
  });
  
  ...생략
}

이렇게 테스트용 Nest 모듈을 만들기 전에 mocking 객체를 만들고 이를 overrideProvider와 useValue 메소드를 통해서 어떤 의존성을 대신할 것인지 지정한다.

** jest-mock-extended라이브러리를 사용해 보다 간단하게 PrismaClient의 동작을 mocking하는 방법이 있다. ([Jest] Prisma Mocking 참고)

3. 유닛 테스트 - 회원 중복 검사

그리고 이번 유닛 테스트에서 예상할 수 있는 결과는 두 가지다.
1. 같은 email을 사용하는 유저다 있다.
2. 같은 email을 사용하는 유저가 없다.

이를 토대로 마찬가지로 todo를 먼저 작성해두자.

describe('Check user duplication', () => {
  it.todo('should return a User if user exists.');
  it.todo('should return null if user does not exist.');
});

3-1. 최소한의 서비스 코드 구현

먼저, 유닛 테스트의 대상이 되는 메소드부터 간단하게 구현을 해놓는다.

// users/users.service.ts
@Injectable()
export class UsersService {
  findUserByEmail(email: string) {
    return null;
  }
}

3-2. 실패하는 테스트 코드

이제 첫번째 테스트 케이스에 해당하는 테스트 코드를 작성한다.

it('should return a User if user exists.', () => {
  const result = service.findUserByEmail(testUser.email);
  expect(result).toEqual(testUser);
});

앞서서 만들어둔 testUser의 이메일을 인자로 전달하여 반환된 결과가 testUser와 일치하는지 확인하는 테스트다.

테스트를 돌려보자.

● UsersService › User Signup › Check user duplication › should return a User if user exists.

    expect(received).toEqual(expected) // deep equality

    Expected: {"email": "test1@delivery.com", "name": "Test Kim", "password": "qwe1234", "type": "customer", "userId": 1}
    Received: null

당연히 실패한다.

3-3. 테스트를 통과하는 최소한의 서비스 코드

이제 이 첫번째 테스트를 통과할 수 있는 최소한의 서비스 코드를 작성한다.

@Injectable()
export class UsersService {
  constructor(
    private readonly prisma: PrismaService,
  ) {}

  findUserByEmail(email: string) {
    return this.prisma.user.findUnique({
      where: { email },
    });
  }
}

✓ should return a User if user exists. (13 ms)

테스트가 성공적으로 통과된 것을 보니 prismaService도 제대로 mocking되었다고 볼 수 있다.

3-4. 리팩토링

Prisma를 ORM으로 쓰는 장점은 강력한 타입 안정성이다. 이를 위해 findUserByEmail 메소드는 다시 아래처럼 리팩토링이 가능하다.

async findUserByEmail(
  where: Prisma.UserWhereUniqueInput
): Promise<User> {
  return this.prisma.user.findUnique({ where });
}

대상 메소드가 받는 인자의 형태가 바뀌었기 때문에 테스트코드도 수정이 필요한데, 수정을 하고 테스트를 돌려보자.

it('should return a User if user exists.', async () => {
  const where = { email: testUser.email };
  const result = await service.findUserByEmail(where);
  expect(result).toEqual(testUser);
});

✓ should return a User if user exists. (1 ms)

리팩토링 후에도 테스트를 통과했다.

3-5. 두번째 테스트 케이스

이제 다시 새로운 실패하는 테스트 케이스를 완성할 차례다.

describe('User Signup', () => {
  const signupForm = {
    email: 'test2@delivery.com',
    name: 'Test Kim',
    password: 'qwe1234',
  }

  describe('Check user duplication', () => {
	...생략

    it('should return null if user does not exist.', async () => {
      const where = { email: signupForm.email };
      const result = await service.findUserByEmail(where);
      expect(result).toBe(null);
    });
  });
});

회원가입 기능을 테스트하는 동안 사용할 회원가입 폼 객체를 하나 만들고, 여기서 이메일을 가지고 왔다.

그리고 이 테스트는 성공할 것이다. 테스트 대상이 되는 findUserByEmail이라는 메소드가 구조가 단순하고, 이미 앞선 테스트로 두번째 테스트 케이스를 통과하기에 충분한 형태가 되었기 때문이다.

Check user duplication
  ✓ should return a User if user exists. (2 ms)
  ✓ should return null if user does not exist. (1 ms)

4. 유틸리티 모듈 Mocking

[보류] 회원가입 폼 유효성 검사
[✅] 회원 유형 확인
[✅] 회원 중복 검사
[] 비밀번호 암호화
[] 회원 데이터 생성

이제 다음은 비밀번호를 암호화해야 시키는 과정을 테스트해야한다. 여기서는 bcrypt라는 라이브러리를 사용할 것인데, 이런 외부 라이브러리는 그 동작을 테스트하기가 어렵다. 따라서, 이런 경우jest.spyOn을 이용하여 bcrypt의 메소드가 성공적으로 호출되었는지 확인하고, 실패했을 경우에는 의도한대로 예외처리가 진행되는지를 살피면 된다.

import * as bcrypt from 'bcrypt';

describe('Encrypt user password', () => {
  const hashedPassword = 'HASHED';
  const bcryptHash = jest.spyOn(bcrypt, 'hash')
    .mockImplementation(() => Promise.resolve(hashedPassword));
});

이렇게 해놓으면 실제로 해싱된 값이 무엇인지 상관없이 hash 메소드가 정상적으로 호출되었는지만 확인할 수 있게 된다. 그리고 실패를 확인할때는 새롭게 mockImplementation만을 바꿔준다.

bcryptHash.mockImplementation(() => Promise.reject(new Error()));

5. 마무리

이후의 남은 나머지 유닛테스트들은 앞서서 진행했던것처럼 똑같이 실패하는 테스트 작성 > 최소한의 구현코드 > 리팩토링을 거쳐서 완성하면 될 것이다. 유닛 테스트가 마무리되었다면 이제 통합테스트로 앞서 구현한 서비스 코드들이 서로 잘 동작하는지 확인해야한다.

0개의 댓글