NestJS

NestJS 테스트 도구 선택: Jest를 코드로 검증했다

Jest·Jasmine·Mocha의 기능표보다 NestJS TestingModule과 Sequelize 모킹 코드를 먼저 작성해 프로젝트에 맞는 테스트 도구를 고른 과정.

이 글의 목차같은 기능을 세 도구로 생각해 봤다7
  1. 같은 기능을 세 도구로 생각해 봤다
  2. 실제 코드와 대역의 경계를 먼저 그렸다
  3. TestingModule로 NestJS의 조립 방식을 유지했다
  4. 도입 뒤에는 실패 결과도 함께 만들어야 한다
  5. 이 테스트가 증명하지 못하는 것도 적었다
  6. 도입 검증에서 확인한 결과
  7. 참고 자료

기존 NestJS 백엔드에 테스트를 붙이는 작업을 맡았을 때 가장 먼저 받은 질문은 “Jest, Jasmine, Mocha 중 무엇이 좋은가”였다.

이름만 비교하면 답은 쉽게 만들 수 있었다.
다운로드 수, 문법, 플러그인 수를 표에 넣고 가장 많이 쓰는 도구를 고르면 된다.

하지만 그 표만으로는 우리가 가진 코드를 실제로 테스트할 수 있는지 알 수 없었다.
서비스는 Sequelize 모델의 정적 메서드를 직접 호출했고, 컨트롤러는 여러 provider를 주입받고 있었다.
Provider는 NestJS가 생성하고 다른 객체에 전달하는 서비스나 저장소 같은 의존성을 뜻한다.
이처럼 객체가 필요한 의존성을 직접 만들지 않고 바깥 컨테이너에서 전달받는 방식을 의존성 주입이라고 한다.

내가 이 프로젝트를 맡았을 때는 테스트 기준이 아직 정해져 있지 않았다.
해야 할 일은 도구 이름을 고르는 데서 끝나지 않았다.
현재 구조에서 의존성을 얼마나 작게 떼어 내 검증할 수 있는지 확인해야 했다.

같은 기능을 세 도구로 생각해 봤다

조사 대상은 Jest, Jasmine과 Mocha였다.

Jasmine은 테스트 문법과 실행기를 함께 제공한다.
Mocha는 실행기를 중심으로 필요한 assertion과 mock 도구를 조합하는 선택지가 넓다.
Assertion은 실제 결과가 예상값과 같은지 판정하는 문장이고, mock은 실제 의존성 대신 정해 둔 결과를 반환하는 대역이다.

Jest는 실행기, assertion, mock과 호출 기록 기능을 한 묶음으로 제공한다.
NestJS의 기본 테스트 예제와 연결하기도 쉬웠다.

이번 비교에서는 “어느 도구가 객관적으로 최고인가”를 결론내리지 않았다.
동일한 서비스에서 다음 세 질문을 짧은 코드로 표현할 수 있는지를 봤다.

  1. NestJS의 의존성 주입 구조를 테스트 안에서도 만들 수 있는가.
  2. 데이터베이스 호출을 실제 연결 없이 격리할 수 있는가.
  3. 반환값뿐 아니라 호출 횟수와 실패 분기까지 읽기 좋게 남길 수 있는가.

그 기준에서 당시 프로젝트에는 Jest가 가장 적은 추가 조합으로 들어왔다.
팀의 기존 NestJS 설정과 TypeScript 코드에 맞는 선택이었지, 다른 프로젝트에서도 언제나 같은 답이라는 뜻은 아니다.

도구별 성격은 다음처럼 판단했다.

도구기본 성격이런 팀에서 검토할 만하다
Jest실행기·assertion·mock을 한 묶음으로 제공NestJS 예제와 가까운 설정으로 빨리 시작하려는 팀
Jasmine테스트 문법과 실행기를 함께 제공이미 Jasmine 규칙과 도구가 쌓인 코드베이스
Mocha실행기를 중심으로 다른 도구를 조합assertion·mock·리포터를 팀이 직접 선택하려는 프로젝트

이 표는 기능의 우열이 아니라 도입 비용의 위치를 보여 준다.
새 NestJS 서비스라면 Jest의 통합된 기본 구성이 편할 수 있고, 기존 Mocha 테스트가 수천 건 있는 서비스라면 도구를 바꾸는 비용이 더 클 수 있다.
테스트 도구를 통일하는 이익보다 기존 테스트를 옮기는 위험이 크다면 현재 도구 위에서 테스트 경계를 개선하는 편이 낫다.

실제 코드와 대역의 경계를 먼저 그렸다

단위 테스트는 한 기능의 판단을 빠르게 확인하는 테스트다.
데이터베이스나 외부 API까지 실제로 연결하면 결과가 네트워크와 데이터 상태에 따라 흔들릴 수 있으므로, 이 경계에서는 의존성을 대역으로 바꾼다.

반대로 모든 것을 mock으로 바꾸면 SQL이 정말 실행되는지, 제약조건과 트랜잭션이 기대대로 작동하는지는 확인할 수 없다.
그 부분은 실제 테스트 데이터베이스를 사용하는 통합 테스트나 HTTP 요청까지 통과시키는 E2E(End-to-End) 테스트가 맡아야 한다.

NestJS 컨트롤러와 서비스는 실제로 구성하고 Sequelize 모델은 Jest 대역으로 바꾸며 실제 SQL은 별도 통합 테스트에서 확인하는 구조
모킹은 테스트를 빠르게 만들지만 실제 SQL까지 증명하지는 않는다. 어떤 경계를 실제로 실행하고 무엇을 대체했는지 함께 적어야 했다.

이번 검증에서는 컨트롤러와 서비스의 판단 코드는 실제로 실행했다.
Sequelize 모델 호출은 jest.spyOn()으로 바꿨다.

spyOn()은 객체의 메서드 호출을 관찰하고 필요하면 반환 동작을 바꾸는 Jest 기능이다.
모델이 실제 데이터베이스를 읽지 않게 만들면서 서비스가 올바른 메서드를 올바른 횟수로 호출하는지 확인할 수 있었다.
단, spyOn()만 호출하면 원래 메서드가 그대로 실행될 수 있다.
이 테스트에서는 바로 뒤의 mockResolvedValueOnce()로 구현을 대체했기 때문에 실제 조회가 일어나지 않는다.

상품 조회 서비스를 예로 들면 단위 테스트는 “저장소가 상품 두 개를 돌려주면 서비스가 그 결과를 가공해 반환하는가”를 빠르게 본다.
통합 테스트는 실제 테스트 DB에서 가격 조건과 정렬 SQL이 맞는지 확인한다.
E2E 테스트는 HTTP 요청, 인증 Guard, 입력 변환과 응답 형식까지 사용자가 지나는 전체 길을 확인한다.

확인하려는 질문알맞은 경계
할인 조건 분기가 맞는가서비스 단위 테스트
Sequelize 모델과 실제 스키마가 맞는가데이터베이스 통합 테스트
인증된 요청이 올바른 HTTP 응답을 받는가E2E 테스트
외부 결제사가 실제로 응답하는가별도 샌드박스·계약 테스트

빠른 단위 테스트만 늘리면 SQL과 프레임워크 연결 오류를 놓치고, 모든 테스트가 실제 데이터베이스를 쓰면 실행이 느리고 실패 원인을 좁히기 어려워진다.
한 종류로 모두 해결하려 하지 않고 질문마다 가장 작은 신뢰 가능한 경계를 고른다.

TestingModule로 NestJS의 조립 방식을 유지했다

NestJS의 Test.createTestingModule()은 테스트용 의존성 주입 컨테이너를 만든다.
운영 코드에서 사용하는 @Module()과 비슷한 방식으로 controller와 provider를 선언한 뒤 compile()로 조립한다.

다음 코드는 당시 검증 방식을 공개용 이름으로 단순화한 예다.

NestJS 서비스와 모델 호출을 격리한 단위 테스트TypeScript단순화 예시조회 전용
describe('CatalogService', () => {
  let service: CatalogService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [CatalogService],
    }).compile();

    service = module.get(CatalogService);
  });

  it('조회 결과를 그대로 반환한다', async () => {
    const expected = [{ id: 1, name: 'sample' }];
    jest.spyOn(CatalogModel, 'findAll').mockResolvedValueOnce(expected);

    await expect(service.findAll()).resolves.toEqual(expected);
    expect(CatalogModel.findAll).toHaveBeenCalledTimes(1);
  });
});

providers에는 테스트할 서비스를 등록한다.
compile()은 NestJS가 의존성을 실제로 조립하게 하므로 생성자 주입 설정이 잘못됐다면 테스트 준비 단계에서 드러난다.

mockResolvedValueOnce()는 비동기 메서드가 한 번만 지정한 값으로 성공하도록 만든다.
반환값을 확인하는 assertion과 호출 횟수 확인을 함께 두어, 결과만 우연히 맞는 경우를 줄였다.

정적 ORM 메서드를 직접 감시하는 방식은 기존 구조를 크게 바꾸지 않고 시작하기에는 유용했다.
다만 테스트가 늘어나면 모델 호출을 repository provider 뒤로 감싸는 편이 낫다.
그렇게 하면 NestJS의 overrideProvider().useValue()로 의존성을 바꿀 수 있고, 전역 정적 메서드의 mock 상태가 다른 테스트에 새는 문제도 줄이기 쉽다.

이 provider 분리는 이번 조사 당시 확인한 변경이 아니라, 현재 기준으로 덧붙이는 설계 권장 사항이다.

처음부터 전체 컨트롤러를 옮기지 않았다.
의존성이 적고 성공·실패 결과를 모두 설명할 수 있는 서비스 하나를 골라 다음 명령으로 해당 테스트 파일만 반복했다.

한 테스트 파일만 빠르게 반복하는 예시Shell후속 설계조회 전용
npm test -- catalog.service.spec.ts

-- 뒤의 값은 npm 자체 옵션이 아니라 package.json의 테스트 스크립트가 실행하는 Jest에 전달된다.
파일 경로나 이름 패턴을 좁히면 도입 중인 한 경계만 빠르게 고칠 수 있다.
전체 회귀 검증에서는 이 제한을 빼고 모든 테스트를 실행해야 한다.

도입 뒤에는 실패 결과도 함께 만들어야 한다

조회 성공 한 건만 있으면 코드가 실패할 때 무엇을 반환하는지 알 수 없다.
도입 검증 다음 단계에서는 같은 의존성에 실패 결과도 넣어야 한다.

비동기 실패 분기 검증TypeScript단순화 예시조회 전용
it('저장소 오류를 서비스 오류로 변환한다', async () => {
  jest
    .spyOn(CatalogModel, 'findAll')
    .mockRejectedValueOnce(new Error('database unavailable'));

  await expect(service.findAll()).rejects.toMatchObject({
    code: 'CATALOG_READ_FAILED',
  });
});

mockRejectedValueOnce()는 Promise가 한 번 실패하도록 만든다.
이 테스트의 목적은 실제 데이터베이스 장애를 재현하는 것이 아니라, 의존성이 실패했을 때 서비스가 약속한 오류 경계를 지키는지 확인하는 데 있다.

입력에 따라 같은 판단을 반복하는 경우에는 파라미터화 테스트도 작성했다.
입력과 기대 결과를 표처럼 나열해 같은 테스트 본문을 여러 번 실행하는 방식이다.
조건 하나가 추가될 때 복사한 테스트가 서로 다르게 변하는 일을 줄일 수 있었다.

Mock 정리 방식도 구분해야 한다.
mockClear()는 호출 기록을 비우지만 구현은 유지한다.
mockReset()은 호출 기록과 함께 mock 구현도 초기화한다.
mockRestore()jest.restoreAllMocks()spyOn()으로 바꾼 메서드를 원래 구현으로 돌려놓는다.
무조건 강한 초기화를 사용하기보다 테스트가 공유해도 되는 설정과 매번 새로 만들어야 하는 상태를 나누는 편이 좋다.

이 테스트가 증명하지 못하는 것도 적었다

모델의 findAll()이 한 번 호출됐다는 사실은 SQL이 효율적이라는 뜻이 아니다.
인덱스를 사용하는지, 조인 결과가 맞는지, 실제 트랜잭션이 롤백되는지는 데이터베이스가 있는 환경에서 따로 확인해야 한다.

컨트롤러 메서드를 직접 호출한 테스트도 실제 HTTP 요청의 모든 경계를 지나지 않는다.
Guard, pipe, interceptor와 exception filter까지 함께 검증하려면 Nest 애플리케이션을 띄우고 E2E 요청을 보내야 한다.

Guard는 요청을 받아도 되는지 판단하고, pipe는 입력값을 검증하거나 변환한다.
Interceptor는 요청 전후의 공통 동작을 감싸며 exception filter는 예외를 HTTP 응답으로 바꾼다.

이 구분을 문서에 남긴 이유는 테스트 개수나 커버리지 숫자가 실제 신뢰성과 같은 값처럼 보이지 않게 하기 위해서였다.
커버리지는 코드의 몇 퍼센트가 실행됐는지를 보여 주지만, 중요한 실패 경계를 제대로 검증했는지는 스스로 설명하지 않는다.

도입 검증에서 확인한 결과

조사는 2023년 4월 6일 완료했다.
Jest·Jasmine·Mocha의 이름을 나열하는 데서 끝내지 않고, NestJS TestingModule로 서비스와 컨트롤러를 구성하고 Sequelize 정적 메서드를 격리하는 예제를 실제로 만들었다.

반환값, 호출 횟수와 파라미터에 따른 분기를 Jest로 표현할 수 있음을 확인했다.
당시 기록에는 전체 테스트 커버리지나 도입 뒤 장애 감소율이 남아 있지 않으므로 그런 성과를 덧붙이지 않았다.

이번 선택에서 오래 남은 기준은 도구의 인기도가 아니었다.
현재 코드의 의존성을 어디서 끊을지, 단위 테스트가 보지 못하는 경계를 어떤 통합 테스트가 맡을지 설명할 수 있어야 실제 도입이 가능했다.

새 프로젝트에서 같은 결정을 한다면 작은 서비스 하나로 시험한 뒤 다음 순서로 넓힌다.

  1. 성공·빈 결과·의존성 실패를 각각 한 건씩 만든다.
  2. 정적 모델 mock이 다른 테스트에 새지 않는지 확인한다.
  3. 실제 SQL이 중요한 저장소에는 테스트 DB를 붙인다.
  4. Guard와 pipe가 중요한 API 한 개를 E2E로 통과시킨다.
  5. CI에서 단위 테스트와 통합 테스트의 실행 조건을 분리한다.

Jest를 골랐다는 사실보다 각 테스트가 무엇을 증명하고 무엇을 증명하지 못하는지 코드 리뷰에서 읽을 수 있게 만드는 것이 목표다.

참고 자료

소신을 상징하는 고래 일러스트
작성자소신 · Soshin

직접 부딪힌 문제와 해결 과정을 다음에 다시 꺼내 볼 수 있도록 기록합니다.