Test Double에 대해 알아보자

윤학·2025년 1월 12일
0

Test

목록 보기
1/1
post-thumbnail

테스트 코드를 작성하다 보면 mock, spy, fake같은 용어들을 접하게 되는데 해당 용어들을 정확히 정리한 적이 없어 Mocks Aren't Stubs의 글을 참고하여 예제와 함께 정리해보려 한다.

하나씩 알아보자.

해당 글에서 도움을 받은 테스트 프레임워크는 Jest입니다.

Test Double

Test Double은 테스트를 목적으로 실제 객체 대신 사용되는 모든 종류의 가짜 객체에 대한 일반 용어라고 한다.

구현 방식이나 사용되는 상황에 따라 5가지 종류로 Test Double을 나눌 수 있다고 한다.

Dummy

Dummy 객체는 전달되지만 실제로 사용되지는 않으며, 일반적으로 단순히 파라미터를 채우기 위해 사용되는 객체다.

만약 아래와 같이 사용자에 대해 생성하고 삭제하는 UserService가 있다고 해보자.
생성할 때는 logger가 사용되지만 삭제할 때는 사용되지 않는다.

하지만 deleteUser를 테스트할 때 logger는 사용되지 않지만, UserService가 logger를 의존하고 있기 때문에 넘겨줘야 테스트를 할 수 있다.

class UserService {
  constructor(
    private readonly logger: Logger,
    private readonly repo: UserRepository
  ) {}

  async createUser(data: userDto) {
    const user = await this.repo.save(data)
    this.logger.log('User Created')
    return user
  }

  async deleteUser(id: number) {
    await this.repo.delete(id)
  }
}

이때 logger를 위한 Dummy 객체를 만들어줘야 한다.

class DummyLogger implements Logger {
  log(message: string) {}
}

describe('UserService Unit Test', () => {
  it('deleteUser', async () => {
    // Given
    const userService = new UserService(new DummyLogger(), new UserRepository());
  });
});

만약 deleteUser에서 logger 역시 검증이 필요하다면 다른 Test Double로 교체되어야 할 것이다.

Fake

실제로 작동하는 구현을 가지고 있지만 프로덕션에 적합하지 않은 객체이다.
메모리 데이터베이스가 좋은 예라고 한다.

interface CouponRepository {
  add(coupon: Coupon): void;
  findById(id: number): Coupon;
}

class MemoryCouponRepository implements CouponRepository {
  private coupons: Map<number, Coupon> = new Map();

  add(coupon: Coupon): Promise<void> {
    this.coupons.set(coupon.id, coupon);
  }

  findById(id: number): Promise<Coupon> {
    return this.coupons.get(id);
  }
}

MemoryCouponRepository를 Map을 이용해서 구현했지만, Array를 이용해서도 구현할 수 있겠다.

핵심은 Fake 객체 내부에서는 상태관리를 하며, 이를 바탕으로 실제 구현체의 기능을 묘사한다.

Stub

미리 정해진 응답만을 반환하며, 비즈니스 로직을 수행하지 않고, 하드 코딩된 값을 반환한다.

class CouponRepositoryStub implements CouponRepository {

  add(coupon: Coupon): Promise<void> {
    return;
  }

  findById(id: number): Promise<Coupon> {
    return new Coupon(id, '쿠폰 이름', 3000);
  }
}

만약 add()도 생성 이후 쿠폰을 반환하는 식으로 구현되어있다면 findById()처럼 하드 코딩된 쿠폰을 반환하는 식으로 작성하면 될 것이다.

Fake vs Stub...?

근데 예제를 Repository로 들어서 그런지 뭔가 Fake랑 비슷해 보인다.

차이점은 로직의 존재 여부와 메서드들간의 연관성인 것 같다.

Fake를 살펴보면 Map을 통해 실제로 Map에 추가하고, Map에서 조회한다.
또한 findById()를 하기 전에 add()를 통해 데이터를 사전에 넣었느냐에 따라 결과가 달라질 수 있다.

하지만 Stub을 살펴보면 로직이 존재하지 않고, findById()를 테스트하는 데 있어 사전에 add()의 실행 여부는 아무런 영향이 없다.

Spy

호출 방식에 따라 일부 정보를 추가로 기록하는 Stub이다.
Stub처럼 미리 정의된 응답을 반환하면서도 호출 시점의 정보를 추가로 기록하는 객체.

대표적인 이메일 발송 로직을 Spy 객체로 구현해봤다.

class EmailServiceSpy {
  private callInfo = [];

  sendEmail(content: any) {
    this.callInfo.push(content);
    return 'OK' <-- 미리 정의된 응답
  }

  getSendCount() {
    return this.callInfo.length;
  }

  getSendContent(nth: number): any {
    return this.callInfo.at(nth - 1);
  }
}

sendEmail()을 수행했을 때 함께 호출된 데이터도 기록한다.

그래서 특정 로직이 끝나고 이메일을 2번 발송하는데 1번째는 사용자한테 2번째는 어드민한테 발송되어야 한다면, 이메일을 실제로 발송하지 않고도 이메일 발송 횟수나 N번째 호출 시점의 인자들도 테스트할 수 있을 것이다.

jest를 이용해서도 쉽게 작성할 수 있다.

    const emailServiceCallSpy = jest.spyOn(emailService, 'sendEmail')

테스트에서 emailService의 sendEmail()이 호출되었다면 emailServiceCallSpy라는 변수 안에 호출과 관련된 정보가 기록될 것이기 때문에 toHaveBeenCalled()등과 같은 다양한 Matcher를 통해 검증할 수 있다.

Mock

호출에 대한 사양을 미리 프로그래밍한 객체이다.
어떤 값과 호출됐을 때 어떤 출력을 기대하는지 미리 작성해 놓는 것이다.

NestJS의 ConfigService를 Jest의 도움을 받아 Mock 객체로 구현해보자.

const mockConfigService = {
  get: jest.fn().mockImplementation((key: string) => {
    switch(key) {
      case 'any1':
        return 'any1';
      case 'any2':
        return 'any2';
    }
  })
};

마치면서

앞으로도 테스트를 작성하다 보면 Test Double로 대체할 순간이 오겠지만,
그럴 때 좀 더 목적과 상황에 맞게 Test Double을 선택하려면 각 방식의 특징을 잘 이해하고 있어야 하지 않을까 싶다.

또한, 테스트 코드도 유지보수의 대상이라는 것을..

0개의 댓글