Posts

사내 테스트코드 도입기

2024-01-05

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

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

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

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

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

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

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

next/image mocking

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

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

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

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 mocking

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

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

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을 정리하고 나서, 가장 먼저 테스트를 작성한 건 가격 계산 유틸이었어요. 할인율 적용, 쿠폰 중복 사용, 최소 주문 금액 체크 같은 케이스들이 얽혀 있어서 손으로 확인하기가 특히 번거로웠던 로직이거든요.

// 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으로 처리할 수 있어요.

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에 드는 초기 비용이 있지만, 그 이후부터는 투자한 시간을 충분히 돌려받고 있어요. 아직 커버리지가 낮은 영역이 많아서 앞으로 조금씩 넓혀갈 계획이에요.