테스트

NestJS에서 createMock으로 단위 테스트 간단하게 작성하는 방법

mweong 2024. 12. 5. 18:25

 

 

 

단위 테스트를 더 간단하게 작성할 수 있는 방법이 없을까...? 🤔



 

 

 

단위 테스트(Unit Test)를 작성하다보면 가장 답답한 부분은 역시 의존성들을 모의(mocking)하는 것입니다.

백엔드에서 단위 테스트를 하면 좋을만한 것은 역시 서비스 레이어일 것입니다.

저는 NestJS에서 외부 API를 호출하여 알림을 전송하는 서비스(NotificationService)를 만들었습니다.

이 서비스의 함수들을 테스트하려면 외부 API를 실제로 연결하지 않고, 마치 연결한 것처럼 모킹하는 과정이 필요했습니다.



비즈니스 로직 & 테스트 코드

 

1. 비즈니스 로직

 

NotificationService는 ConfigService, HttpService, LoggerService 세 개의 의존성을 가지고 있습니다.

 

디스코드 웹훅을 사용해 디스코드 특정 채널로 메세지를 전송 후 성공 또는 실패했을 때 로그를 남기는 간단한 로직입니다.

 

@Injectable()
export class NotificationService {
  private webhookUrl: string;
  private readonly webhookName = 'webhook profile name';

  constructor(
    private readonly configService: ConfigService,
    private readonly httpService: HttpService,
    private readonly logger: LoggerService,
  ) {
    this.logger.setContext(NotificationService.name);
    this.webhookUrl = this.configService.get<ServerConfig>(SERVER_CONFIG_TOKEN).webhookUrl;
  }

  async reportError(errorContent: ErrorContent) {
    const message = {
      // 생략...
    };

    try {
      await this.httpService.axiosRef.post(this.webhookUrl, message);
      this.logger.log('Error Notification is sent to the Discord channel.');
    } catch (error) {
      if (error instanceof AxiosError) {
        this.logger.warn(error.message, error.response.data);
      }
    }
  }
}

 

 

 

2. 테스트 코드 (v1)

 

reportError() 함수가 잘 동작하는지 확인하기 위해 테스트 코드를 아래와 같이 작성했습니다.

 

// v1-notification.service.spec.ts

describe('NotificationService', () => {
  let notificationService: NotificationService;
  let httpService: HttpService;
  let logger: LoggerService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        NotificationService,
        {
          provide: HttpService,
          useValue: {
            axiosRef: {
              post: jest.fn(),
            },
          },
        },
        {
          provide: LoggerService,
          useValue: {
            setContext: jest.fn(),
            log: jest.fn(),
            warn: jest.fn(),
          },
        },
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn(() => ({ webhookUrl: 'test_url' })) 
          },
        },
      ],
    }).compile();

    notificationService = module.get<NotificationService>(NotificationService);
    httpService = module.get<HttpService>(HttpService);
    logger = module.get<LoggerService>(LoggerService);
  });

  it('should be defined', () => {
    expect(notificationService).toBeDefined();
  });

  test('메시지 전송을 성공하면, info 레벨의 로그를 남긴다.', async () => {
    // given
    const postSpy = jest
    	.spyOn(httpService.axiosRef, 'post')
        .mockResolvedValue({ status: 204 });
    const logSpy = jest.spyOn(logger, 'log');

    // when
    await notificationService.reportError({
      method: 'GET',
      url: '/api/test',
      status: 500,
      timestamp: '2024-12-01T12:00:00Z',
      userId: 'user-123',
      trace: 'Stack trace details...',
      context: 'AllExceptionFilter',
    });

    // then
    expect(postSpy).toHaveBeenCalledTimes(1);
    expect(logSpy).toHaveBeenCalledTimes(1);
  });

  test('메시지 전송을 실패하면, warn 레벨의 로그를 남긴다.', async () => {
    // given
    const postSpy = jest
      .spyOn(httpService.axiosRef, 'post')
      .mockRejectedValue(
        new AxiosError('Request failed', null, null, null, { data: 'Error details' } as AxiosResponse),
      );
    const warnSpy = jest.spyOn(logger, 'warn');

    // when
    await notificationService.reportError({
      method: 'GET',
      url: '/api/test',
      status: 500,
      timestamp: '2024-12-01T12:00:00Z',
      userId: 'user-123',
      trace: 'Stack trace details...',
      context: 'AllExceptionFilter',
    });

    // then
    expect(postSpy).toHaveBeenCalledTimes(1);
    expect(warnSpy).toHaveBeenCalledTimes(1);
  });
});

테스트 통과 🎉




 

테스트 자체는 굉장히 단순하지만 beforeEach()를 작성하는 부분이... 너무 귀찮았습니다.

비즈니스 로직과 비교하면서 의존성을 모의 객체로 작성해야 했는데, 사실상 TDD가 불가능한 것이 아닌가 싶었습니다.

비즈니스 로직 내부에 어떤 함수와 값들이 쓰일지 모두 알고 있어야 하니까요.

 



이 작업을 단순화할 수 있는 방법을 찾아보다가, @golevelup/ts-jest라는 모듈을 발견했습니다.


NestJS 공식문서를 보면 아래와 같은 내용이 있습니다. ⬇️


useMocker를 통해 @golevelup/ts-jest의 createMock이라는 mock factory를 전달할 수 있다고 합니다.

https://docs.nestjs.com/fundamentals/testing#auto-mocking

 

 

 

 

@golevelup/ts-jest 에서 제공하는 기능

 

 

 

하위 속성이 너무 많아서 모의 객체를 만들기 어려운 경우 에 사용할 수 있는 NestJS용 유틸리티 함수 createMock을 제공합니다.

 

모든 하위 속성을 jest.fn()으로 모의하여 모의 객체를 만들어준다고 하네요. 

 

NestJS의 HttpService를 모의할 때 사용하면 딱 좋겠다는 생각이 들었습니다. 

 

 

createMock 적용해보기

 

적용 방법은 굉장히 간단합니다.

 

@nestjs/testing 라이브러리를 사용하여 테스팅 모듈을 생성할 때,

 

useMocker() 함수로 의존성에 대한 모의 객체를 전달만 해주면 되는데요.

 

// v2-notification.service.spec.ts

describe('NotificationService', () => {
  let notificationService: NotificationService;
  let httpService: HttpService;
  let logger: LoggerService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [NotificationService],
    })
      .useMocker(createMock)  // ⬅️ Here!
      .compile();

    notificationService = module.get<NotificationService>(NotificationService);
    httpService = module.get<HttpService>(HttpService);
    logger = module.get<LoggerService>(LoggerService);
  });

  it('should be defined', () => {
    expect(notificationService).toBeDefined();
  });

  test('메시지 전송을 성공하면, info 레벨의 로그를 남긴다.', async () => {
    // 생략...
  });

  test('메시지 전송을 실패하면, warn 레벨의 로그를 남긴다.', async () => {
    // 생략...
  });
});

 

 

특정 의존성만 createMock으로 모의하고 싶다면 아래처럼 작성하면 됩니다. ⬇️

 

const module = await Test.createTestingModule({
  providers: [
    NotificationService,
    {
      provide: HttpService,
      useValue: createMock<HttpService>(),
    },
    {...}
  ],
}).compile();

 

 

결과

테스트 내용은 변경하지 않고 createMock을 적용했을 때도 마찬가지로 모든 테스트가 통과했습니다.

테스트 통과 👏

 

 

참고