PUBLISHED

Jest

작성일: 2024.12.07

Jest

프론트엔드든 백엔드든, 테스트를 어디까지 작성해야 하는지는 늘 논쟁거리다. 모든 계층을 촘촘하게 테스트하는 방식도 있고, 핵심 로직에만 집중하는 방식도 있다. 나는 후자에 가깝다. 컨트롤러나 레졸버, 레포지토리 계층까지 전부 테스트하기보다는, 비즈니스 로직이 모여 있는 서비스 계층을 중심으로 테스트를 작성하는 편이다.

이런 선택에는 이유가 있다. 컨트롤러나 레포지토리는 대부분 프레임워크나 ORM의 규칙을 따르는 얇은 계층이고, 실제로 버그가 발생하는 지점은 거의 항상 “의사결정 로직”에 있다. 그래서 테스트 역시 프레임워크 사용법을 검증하기보다는, 내가 작성한 코드의 분기와 규칙을 검증하는 데 집중하는 것이 효율적이라고 생각한다. 이 글에서는 그런 관점에서 Jest를 어떻게 사용하고 있는지를 정리해보려 한다.

Jest를 선택한 이유

Jest는 “편하다”는 말로 설명이 끝나는 테스트 러너다. 설정이 적고, 기본 제공 기능이 많으며, Nest.js와의 궁합도 매우 좋다. 특히 별도의 설정 없이도 mock, spy, matcher가 한 번에 제공된다는 점은 테스트 작성의 진입 장벽을 크게 낮춰준다.

무엇보다 Jest는 “테스트 프레임워크”이면서 동시에 “테스트 도구 상자”다. assertion, mocking, fake timer, coverage 측정까지 테스트에 필요한 거의 모든 것을 하나의 문법 체계로 제공한다. 덕분에 테스트 코드 역시 애플리케이션 코드처럼 일관된 스타일을 유지할 수 있다.

테스트의 기본 구조

Jest 테스트는 기본적으로 describeit(test) 블록으로 구성된다. 구조 자체는 단순하지만, 나는 이 구조를 의미 단위로 분리하는 데 신경을 쓰는 편이다.

untitled
JS
describe('AuthService', () => {
  describe('signIn', () => {
    it('유효한 이메일과 비밀번호가 주어지면 토큰을 반환한다', () => {
      // ...
    });
  });

  describe('signUp', () => {
    it('이미 존재하는 이메일이면 예외를 던진다', () => {
      // ...
    });
  });
});

이렇게 나누어두면, 테스트 실패 로그만 봐도 어디서 어떤 규칙이 깨졌는지 바로 파악할 수 있다.

beforeEach와 테스트 격리

나는 거의 모든 테스트에서 beforeEach를 사용해 테스트 환경을 초기화한다. 테스트 간 상태 공유는 생각보다 많은 문제를 만든다. 특히 mock 함수의 호출 횟수나 반환값이 이전 테스트의 영향을 받는 경우는 디버깅 난이도를 크게 높인다.

untitled
JS
beforeEach(() => {
  jest.clearAllMocks();
});

Nest.js 테스트에서는 Test.createTestingModulebeforeEach에서 호출해 매 테스트마다 새로운 DI 컨테이너를 생성한다. 느릴 수는 있지만, side effect를 제거하는 비용이라고 생각하면 충분히 감수할 만하다.

expect와 matcher

Jest의 expect는 테스트의 핵심이다. 나는 한 테스트에서 너무 많은 expect를 쓰는 것을 선호하지 않는다. 하나의 테스트는 하나의 규칙을 검증하는 데 집중하는 편이 좋다.

untitled
TS
expect(result).toBeDefined();
expect(result.email).toBe(email);

자주 사용하는 matcher는 다음 정도로 고정되어 있다.

특히 객체 전체를 비교할 때는 toEqual보다는 toMatchObject를 더 자주 쓴다. 테스트는 “정확한 구현”보다 의미 있는 결과를 검증하는 게 목적이기 때문이다.

Mocking

Jest에서 가장 강력한 기능은 단연 mocking이다. 나는 실제 구현을 호출하지 않아도 되는 모든 의존성은 mock으로 대체한다.

untitled
TS
const repository = {
  findById: jest.fn(),
  create: jest.fn(),
};

mock 함수는 단순히 값을 반환하는 용도뿐 아니라, 호출 여부와 호출 인자를 검증하는 데도 사용한다.

untitled
TS
expect(repository.create).toHaveBeenCalledWith({
  email,
  password,
});

이 방식은 “이 메서드가 호출되었는가?”보다 “어떤 의도를 가지고 호출되었는가?”를 검증하는 데 적합하다.

spyOn

이미 존재하는 객체의 메서드를 감시하고 싶을 때는 jest.spyOn을 사용한다. 특히 Date, Math, console 같은 전역 객체를 다룰 때 유용하다.

untitled
JS
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

테스트가 끝난 뒤에는 반드시 원래 구현으로 복구한다.

untitled
spy.mockRestore();

spy는 mock보다 “침습적”이기 때문에, 필요한 경우에만 사용하는 게 좋다.

비동기 테스트

Jest에서 비동기 테스트는 크게 두 가지 방식으로 작성할 수 있다.

untitled
JS
await expect(service.run()).resolves.toBe(true);
await expect(service.run()).rejects.toThrow();

나는 try/catch로 감싸는 방식보다 이 문법을 더 선호한다. 실패했을 때의 로그가 훨씬 명확하기 때문이다.

Fake Timer

시간에 의존하는 로직을 테스트할 때는 fake timer가 유용하다.

untitled
JS
jest.useFakeTimers();

setTimeout(fn, 1000);
jest.advanceTimersByTime(1000);

expect(fn).toHaveBeenCalled();

다만 fake timer는 전역 상태를 바꾸기 때문에, 테스트가 끝난 뒤에는 반드시 원래 상태로 되돌린다.

untitled
jest.useRealTimers();

마무리

내가 Jest를 쓰면서 가장 중요하게 생각하는 점은 테스트가 코드의 사용 설명서가 되어야 한다는 것이다. 테스트를 읽으면 이 메서드가 어떤 입력을 받는지, 어떤 상황에서 실패하는지, 그리고 무엇을 보장하는지가 드러나야 한다. 그런 이유로 테스트 코드에는 종종 구현 코드보다 더 많은 설명이 들어간다. 테스트는 단순한 검증 수단이 아니라, 실행 가능한 문서다.

Jest는 이런 테스트를 작성하는 과정을 어렵게 만들지 않는다. 설정에 시간을 쓰지 않아도 되고, 필요한 기능들이 기본적으로 잘 갖춰져 있어 테스트 자체에 집중할 수 있다. 나는 Jest를 통해 완벽한 커버리지를 달성하는 것을 목표로 삼지 않는다. 대신 중요한 규칙이 깨지지 않도록 최소한의 보호막을 두는 데 의미를 둔다.

테스트는 품질을 보장하는 마지막 단계라기보다는, 설계를 점검하는 첫 번째 단계에 가깝다고 생각한다. 테스트를 작성하다 보면 책임이 모호한 코드가 보이고, 의도가 분명하지 않은 로직이 드러난다. Jest는 그 과정을 가장 자연스럽게 만들어주는 도구 중 하나다.