레시피 목록

사내 테스트코드 도입기

2024년 1월 5일 5분 읽기
테스트

작년 5월 이직 후, 회사에서 담당한 프로젝트를 지속적으로 리팩토링하고 기능을 추가해왔습니다. 그런데 테스트 코드 없이 로직을 검증하려니 생각보다 많은 시간이 들었고, 그러면서 테스트 코드의 필요성을 절실히 깨달았죠.

특히 고통스러웠던 건 특정 시간에만 오픈되는 UI를 확인할 때였습니다. 조건을 맞추려고 시스템 시간을 바꾸거나 코드를 임시로 수정하는 과정이 매번 반복됐습니다. 복잡한 가격 계산 로직이나 케이스가 많은 분기 처리를 바꿀 때마다, 모든 경우를 손으로 찍어보는 게 그 자체로 일이었고요. 다행히 다른 팀의 신규 프로젝트 준비로 잠시 여유가 생겼습니다. 미루고 미뤄뒀던 테스트 코드를 드디어 도입하기로 했습니다.

Jest로 시작했다가 Vitest로 돌아섰다

처음엔 Jest를 쓰려고 했는데, 최종적으로는 Vitest를 선택했습니다.

항목JestVitest
초기 설정복잡한 설정 필요간단한 설정
속도상대적으로 느림매우 빠름
Vite 통합별도 설정 필요네이티브 지원
GUI별도 도구 필요@vitest/ui 제공

속도 차이가 체감될 만큼 나는 데는 이유가 있습니다. Jest는 기본적으로 CommonJS 기반이라 ESM 코드를 실행하려면 Babel이나 ts-jest 같은 트랜스파일러를 거쳐야 합니다. 반면 Vitest는 Vite를 그대로 쓰니까 native ESM으로 동작하고, esbuild가 트랜스파일을 처리합니다. 변환 과정이 훨씬 가볍고, Vite의 모듈 그래프를 공유하니까 이미 처리된 모듈을 재사용할 수도 있고요. 기존 프로젝트가 Vite 기반이라면 설정 파일 하나로 바로 시작할 수 있다는 것도 큰 장점이었습니다.

레퍼런스가 Jest보다 적다는 점은 걱정이었습니다. 그런데 API 자체가 Jest와 90% 이상 유사해서 실제로 막히는 경우는 거의 없었습니다. 특히 @vitest/ui를 통한 GUI가 깔끔해서 테스트 결과를 브라우저에서 시각적으로 확인할 수 있었고, 개발 효율이 꽤 올라갔습니다.

__tests__ 폴더 하나에 다 몰아넣었더니

처음엔 __tests__ 폴더를 만들어서 하위에 모든 테스트 파일을 몰아넣었습니다. 그런데 실제로 쓰다 보니 테스트 파일을 열 때마다 디렉토리를 오가는 게 생각보다 불편했습니다. Button.tsx를 보다가 테스트를 확인하려면 완전히 다른 경로로 이동해야 했고, 컴포넌트 구조가 깊어질수록 테스트 파일도 같이 깊어져서 찾는 것 자체가 일이 됐죠.

결국 하이브리드 방식으로 바꿨습니다. 각 컴포넌트의 테스트 파일은 컴포넌트 바로 옆에 두고, 공통 유틸이나 setup 파일만 __tests__ 폴더에 모아두는 방식입니다.

code
components/
├── Button/
│   ├── Button.tsx
│   └── Button.test.tsx  ← 바로 옆에 위치
__tests__/
├── utils/
└── setup.ts

컴포넌트를 수정하면서 바로 옆에서 테스트를 열 수 있으니 훨씬 자연스러웠습니다.

Next.js 환경에서 만난 문제들

next/image가 테스트에서 다른 src를 뱉는다

Next.js의 Image 컴포넌트는 브라우저에서 실제로 <img> 태그를 그대로 출력하지 않습니다. 내부적으로 최적화 파이프라인을 거쳐서 URL을 변환합니다.

code
원본: "testImage.com"
변환: "/_next/image?url=testImage.com&w=640&q=50"

테스트 환경에서는 이 최적화 파이프라인이 동작하지 않으니, src 속성을 원본 값으로 확인하려는 테스트가 실패합니다. next/image 자체를 mocking해서 일반 <img> 태그를 반환하도록 처리했습니다.

ts
import { vi } from "vitest";

const mockNextImage = () => {
  vi.mock("next/image", () => ({
    __esModule: true,
    default: (
      props: React.DetailedHTMLProps<
        React.ImgHTMLAttributes<HTMLImageElement>,
        HTMLImageElement
      >
    ) => {
      return <img alt={props.alt} {...props} />;
    },
  }));
};

export { mockNextImage };

next/dynamic으로 가져온 컴포넌트가 아예 안 뜬다

dynamic으로 import한 컴포넌트는 테스트 환경에서 로드되지 않는 문제가 있었습니다. Next.js의 dynamic은 lazy loading을 지원하려고 Suspense 기반으로 동작하는데, jsdom 환경에서는 이 동적 import의 비동기 로딩이 기대한 대로 처리되지 않습니다. dynamic으로 가져온 컴포넌트가 렌더링되지 않으니 관련 테스트가 전부 실패했습니다.

해결은 next/dynamic 자체를 mocking해서, 내부적으로는 실제 모듈을 즉시 로드하도록 바꾸는 거였습니다.

ts
vi.mock("next/dynamic", async () => {
  const dynamicModule: any = await vi.importActual("next/dynamic");
  return {
    default: (loader: any) => {
      const dynamicActualComp = dynamicModule.default;
      const RequiredComponent = dynamicActualComp(() =>
        loader().then((mod: any) => mod.default || mod)
      );

      // for debugging
      if (RequiredComponent?.render?.displayName) {
        RequiredComponent.render.displayName = loader.toString();
      }

      RequiredComponent.preload
        ? RequiredComponent.preload()
        : RequiredComponent.render.preload();

      return RequiredComponent;
    },
  };
});

가장 먼저 테스트를 붙인 곳은 가격 계산이었다

Next.js mocking을 정리하고 나서, 가장 먼저 테스트를 작성한 건 가격 계산 유틸이었습니다. 할인율 적용, 쿠폰 중복 사용, 최소 주문 금액 체크 같은 케이스들이 얽혀 있어서 손으로 확인하기가 특히 번거로웠던 로직입니다.

ts
// utils/price.test.ts
import { describe, it, expect } from 'vitest';
import { calculateFinalPrice } from './price';

describe('calculateFinalPrice', () => {
  it('할인율 없이 원가를 반환한다', () => {
    expect(calculateFinalPrice(10000, { discount: 0 })).toBe(10000);
  });

  it('할인율이 적용된 가격을 반환한다', () => {
    expect(calculateFinalPrice(10000, { discount: 0.1 })).toBe(9000);
  });

  it('쿠폰과 할인율이 동시에 적용된다', () => {
    // 10000원에서 10% 할인 후 1000원 쿠폰 적용
    expect(calculateFinalPrice(10000, { discount: 0.1, coupon: 1000 })).toBe(8000);
  });

  it('최종 가격이 0원 미만이면 0원을 반환한다', () => {
    expect(calculateFinalPrice(500, { discount: 0.5, coupon: 1000 })).toBe(0);
  });
});

이런 케이스들을 손으로 일일이 확인하려면 코드를 임시로 수정하거나 콘솔에 찍어가며 하나씩 검증해야 합니다. 테스트로 작성해두면 로직을 바꿀 때마다 한 번에 확인되고요.

그렇게 고생했던 시간 의존 로직도 같은 방식으로 풀렸습니다. 기존에는 특정 시간에만 열리는 기능을 확인하려고 시스템 시간을 직접 바꿔야 했는데, Vitest의 vi.setSystemTime으로 처리할 수 있습니다.

ts
import { vi, describe, it, expect, afterEach } from 'vitest';
import { isFlashSaleActive } from './sale';

describe('isFlashSaleActive', () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it('플래시 세일 시간(14:00~16:00)에 true를 반환한다', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2024-01-01T15:00:00'));

    expect(isFlashSaleActive()).toBe(true);
  });

  it('플래시 세일 시간 외에는 false를 반환한다', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2024-01-01T10:00:00'));

    expect(isFlashSaleActive()).toBe(false);
  });
});

시스템 시간을 코드로 제어할 수 있다는 것만으로, "시간 관련 로직은 테스트하기 어렵다"는 막연한 두려움이 사라졌습니다.

마치며

테스트 코드를 도입하고 나서 가장 크게 달라진 건 수정에 대한 부담감이었습니다. 로직을 건드릴 때마다 손으로 케이스를 하나씩 확인하던 게, 테스트를 돌리고 결과를 보는 것으로 바뀌었습니다.

처음엔 "테스트 코드를 작성하는 시간"이 아깝게 느껴지기도 했습니다. 그런데 한 달쯤 지나니 관점이 바뀌더라고요. 코드를 바꿀 때마다 "이게 혹시 다른 케이스를 깨뜨리지 않을까" 하는 불안감이 확실히 줄어들었습니다. 특히 가격 계산처럼 경우의 수가 많은 코드는 테스트가 있고 없고의 차이가 크게 느껴졌습니다.

셋업과 mocking에 드는 초기 비용은 분명히 있지만, 그 이후부터는 투자한 시간을 충분히 돌려받고 있습니다. 아직 커버리지가 낮은 영역이 많아서 앞으로 조금씩 넓혀갈 생각입니다. 미루고 계신 분이 있다면, 가장 손으로 찍어보기 귀찮았던 유틸 하나부터 시작해보시면 어떨까 싶습니다.