[NestJS] 테스트를 하며 배운 것

허창원·2024년 3월 7일
0
post-custom-banner

jest에서 직접 모킹하는 것과 jest.spyOn의 차이점

nestjs 프로젝트를 진행하며 테스트를 진행했습니다. 이때,레포지토리를 변수로 직접 모킹하였습니다.

// espense.service.spec.ts
const mockUserRepository = {
  findOne: jest.fn(),
}
const mockBudgetRepository = {
  createQueryBuilder: jest.fn(),
}
const mockExpenseRepository = {
  create: jest.fn(),
  save: jest.fn(),
  findOne: jest.fn(),
  delete: jest.fn(),
  createQueryBuilder: jest.fn(),
}

const mockCategoryRepository = {
  findOne: jest.fn(),
}

describe('ExpenseService', () => {
  let service: ExpenseService
  let userRepository: UserRepository
  let budgetRepository: Repository<Budget>
  let expenseRepository: Repository<Expense>
  let categoryRepository: Repository<Category>

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ExpenseService,
        {
          provide: UserRepository,
          useValue: mockUserRepository,
        },
        {
          provide: getRepositoryToken(Budget),
          useValue: mockBudgetRepository,
        },
        {
          provide: getRepositoryToken(Expense),
          useValue: mockExpenseRepository,
        },
        {
          provide: getRepositoryToken(Category),
          useValue: mockCategoryRepository,
        },
      ],
    }).compile()

    service = module.get<ExpenseService>(ExpenseService)
    userRepository = module.get<UserRepository>(UserRepository)
    budgetRepository = module.get<Repository<Budget>>(
      getRepositoryToken(Budget),
    )
    expenseRepository = module.get<Repository<Expense>>(
      getRepositoryToken(Expense),
    )
    categoryRepository = module.get<Repository<Category>>(
      getRepositoryToken(Category),
    )
  })

테스트를 작성하면서 mockExpseRepository를 변수를 활용해 직접 모킹하는 방법, jest.spyOn을 사용하여 모킹하는 방법이 있다는 것을 알게 되었습니다. 이 두 방법에 대해 차이점이 무엇인지 알아보겠습니다.

mockExpenseRepository.create.mockReturnValue(expectedExpense);
mockExpenseRepository.save.mockResolvedValue(expectedExpense);

jest.spyOn(expenseRepository, 'create').mockReturnValue(expectedExpense)
jest.spyOn(expenseRepository, 'save').mockResolvedValue(expectedExpense)

1. 직접 모킹 (mockReturnValue / mockResolvedValue 사용)

mockExpenseRepository.create.mockReturnValue(expectedExpense);
mockExpenseRepository.save.mockResolvedValue(expectedExpense);
  • 직접 모킹 방식mockExpenseRepository 객체를 생성하고 해당 객체의 메소드를 직접 오버라이드합니다. 이 경우 create 메소드는 동기적으로 expectedExpense를 반환하고, save 메소드는 비동기적으로 expectedExpense를 반환하도록 설정됩니다.
  • 이 접근 방식은 주로 단순하고 컨트롤이 쉬운 테스트 환경을 필요로 할 때 유용합니다. 테스트 대상이 되는 서비스나 컨트롤러가 실제 expenseRepository의 인스턴스를 사용하지 않고, 대신 테스트를 위해 제공된 mockExpenseRepository 인스턴스를 사용한다고 가정할 때 적합합니다.

2. jest.spyOn 사용

jest.spyOn(expenseRepository, 'create').mockReturnValue(expectedExpense);
jest.spyOn(expenseRepository, 'save').mockResolvedValue(expectedExpense);
  • jest.spyOn 방식은 실제 expenseRepository 객체의 createsave 메소드를 감시(spy)하고, 이 메소드들이 호출될 때마다 지정된 동작을 수행하도록 설정합니다. 이 경우에도 create는 동기적으로, save는 비동기적으로 expectedExpense를 반환하도록 모킹됩니다.
  • 이 접근 방식은 실제 객체의 메소드 호출을 감시하고 싶을 때 유용합니다. 즉, 메소드가 실제로 호출되었는지, 어떤 인자로 호출되었는지 등을 검증하고 싶을 때 사용할 수 있습니다. 이 방식은 테스트 대상 코드가 실제 객체를 사용하면서도 특정 메소드의 동작만 변경하고 싶을 때 적합합니다.

결론적인 차이점

  • 직접 모킹은 테스트를 위한 전체 모의 객체를 생성하고 설정하는 반면, jest.spyOn은 실제 객체의 메소드 호출을 감시하고 필요에 따라 그 동작을 재정의합니다.
  • jest.spyOn을 사용하면 원본 메소드의 호출 여부와 인자 등을 검증할 수 있으며, 테스트가 끝난 후 mockRestore를 호출하여 원래 상태로 복원할 수 있는 옵션이 있어 테스트 간의 상태 격리를 용이하게 합니다.
  • 선택하는 접근 방식은 테스트의 목적, 필요한 제어 수준 및 테스트 환경의 복잡성에 따라 달라집니다.

Mock과 Stub의 차이점

mock과 stub은 테스트 환경에서 사용되는 가짜 객체를 말한다.

Mock

Mock 객체는 테스트 대상의 외부 의존성을 가짜로 만든 객체입니다. 테스트 중에 해당 객체가 어떻게 사용되는지 검증하는 데 사용합니다. 테스트 대상이 Mock 객체와 상호작용이 올바른지 검증합니다. 호출된 메서드, 전달된 매개변수, 호출 횟수 등을 검사하여 테스트 대상이 예상대로 외부와 상호작용하는지 확인할 수 있습니다.
Mock에 대한 예시 코드입니다.
주문 상태를 업테이트 하는 서비스(OrderService)가 결제 시스템(PaymentService)을 의존한다고 가정합니다.

1.PaymentService 인터페이스

먼저, 결제와 관련된 로직을 처리하는 서비스의 인터페이스를 정의합니다.

// payment.service.ts
export interface PaymentService {
  validatePayment(orderId: string): Promise<boolean>;
}

2. OrderService

PaymentService를 사용하여 주문의 결제 상태를 업데이트하는 서비스입니다.

// order.service.ts
import { Injectable } from '@nestjs/common';
import { PaymentService } from './payment.service';

@Injectable()
export class OrderService {
  constructor(private paymentService: PaymentService) {}

  async updateOrderStatus(orderId: string): Promise<string> {
    const isValid = await this.paymentService.validatePayment(orderId);
    return isValid ? '결제 확인됨' : '결제 실패';
  }
}

3. 테스트 코드

위 서비스 코드에 대해 Jest를 사용해서 OrderService의 테스트 코드를 작성합니다. 이 과정에서 PaymentService의 Mock 객체를 생성하고 validatePayment 메서드가 특정한 값을 반환하도록 설정합니다.

// order.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { OrderService } from './order.service';
import { PaymentService } from './payment.service';

describe('OrderService', () => {
  let service: OrderService;
  let paymentServiceMock: PaymentService;

  beforeEach(async () => {
    // PaymentService의 목 객체 생성
    const paymentServiceMockProvider = {
      provide: PaymentService,
      useFactory: () => ({
        validatePayment: jest.fn((orderId: string) => Promise.resolve(true)), // 항상 true를 반환하도록 설정
      }),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [OrderService, paymentServiceMockProvider],
    }).compile();

    service = module.get<OrderService>(OrderService);
    paymentServiceMock = module.get<PaymentService>(PaymentService);
  });

  it('should return "결제 확인됨" when payment is valid', async () => {
    // 목 객체의 메서드가 예상대로 호출되었는지 검증
    expect(await service.updateOrderStatus('123')).toBe('결제 확인됨');
    expect(paymentServiceMock.validatePayment).toHaveBeenCalledWith('123');
  });
});

jest.fn()을 사용해서 PaymentService의 validatePayment 메서드를 Mock으로 사용했습니다. 이 Mock 메서드는 모든 orderId에 대해 true로 반환하도록 설정되어 있습니다. 테스트는 OrderService가 PaymentService.validatePayment 메서드가 호출되고, 예상된 인자를 받는지 확인하고 '결제 확인됨'을 반환하는지 확인합니다.

Stub

Stub이란 테스트 중인 모듈이 호출하는 다른 소프트웨어 구성요소(예: 모듈, 변수, 객체)를 일시적으로 대체하는 소프트웨어 구성요소를 말합니다. Stub을 사용하는 경우는 아래와 같습니다.

  1. 호출하는 함수가 아직 구현되지 않았을 때
  2. 함수가 반환하는 값을 임의로 생성
  3. 복잡한 논리 흐름을 가지는 경우 테스트를 단순화할 목적으로 사용

Stub에 대한 예를 들어보겠습니다. 온라인 쇼핑몰에서 사용자의 주문 내역을 가져오는 기능을 개발한다고 가정합니다. 이 기능은 외부 결제 시스템 API를 호출하여 사용자가 결제한 주문의 상태를 확인해야합니다. 단위 테스트를 진행할 때, 실제 결제 시스템이 아직 구현되지 않았다고 가정하고 Stub 객체를 사용하여 테스트합니다.

Stub를 사용하기 위해서는 결제 시스템 API 호출 부분을 객체로 대체합니다. 이 Stub 객체는 주문 ID '1234'가 입력되었을 때, '결제 완료'라는 고정된 응답을 반환하도록 구현됩니다.

1. PaymentClient 인터페이스

먼저, 외부결제 시스템과 통신하기 위해 클라이언트의 인터페이스를 정의합니다.

// payment.client.ts

export interface PaymentClient {
  getOrderStatus(orderId: string): Promise<string>;
}

2. PaymentClient

// payment.client.ts
import { Injectable } from '@nestjs/common';
import { PaymentClient } from './payment.client';

@Injectable()
export class PaymentClientImpl implements PaymentClient {
  async getOrderStatus(orderId: string): Promise<string> {
    // 실제 외부 API 호출 로직이 구현안된
    return '결제 완료'; // 예시이므로 간단한 반환값으로 대체
  }
}

3. PaymentClient Stub

테스트에 사용할 PayClient를 stub로 구현했습니다.

// payment.client.stub.ts
import { PaymentClient } from './payment.client';

export class PaymentClientStub implements PaymentClient {
  async getOrderStatus(orderId: string): Promise<string> {
    if (orderId === '1234') {
      return '결제 완료';
    }
    return '대기 중';
  }
}

4. OrderService

PaymentClient를 사용하여 주문 상태를 확인하는 서비스입니다.

// payment.client.stub.ts
import { PaymentClient } from './payment.client';

export class PaymentClientStub implements PaymentClient {
  async getOrderStatus(orderId: string): Promise<string> {
    if (orderId === '1234') {
      return '결제 완료';
    }
    return '대기 중';
  }
}

5. 테스트 코드

// order.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { OrderService } from './order.service';
import { PaymentClient } from './payment.client';
import { PaymentClientStub } from './payment.client.stub';

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      // OrderService에 PaymentClientStub을 주입
      providers: [
        OrderService,
        {
          provide: PaymentClient,
          useClass: PaymentClientStub,
        },
      ],
    }).compile();

    service = module.get<OrderService>(OrderService);
  });

  it('should return "결제 완료" for orderId 1234', async () => {
    expect(await service.checkOrderStatus('1234')).toBe('결제 완료');
  });
});

위 코드에서 PaymentClientStub은 테스트 중에 OrderService가 PaymentClient의 실제 구현 대신 사용하는 Stub입니다. 이 Stub는 PaymentClient 인터페이스를 구현하며, getOrderStatus 메서드에서 주어진 조건에 따라 고정된 응답을 반환합니다. 이를 통해 외부 API의 실제 통신 없이 OrderService의 로직을 테스트할 수 있습니다.

post-custom-banner

0개의 댓글