콘텐츠로 이동

vitest + RTL + happy-dom — Vite + Tailwind v4 프로젝트 첫 도입 패턴

맥락

Vite + Tailwind v4 + shadcn/ui 스택에 vitest를 처음 도입할 때 발생하는 설정 충돌과 Radix UI 호환 문제를 다룬다. 기존에 테스트 인프라가 전혀 없는 상태에서 시작하는 경우를 전제한다.

관련 스택: React 19 + TypeScript + Vite 7 + Tailwind v4 + shadcn/ui + TanStack Query v5

가이드

1. vitest.config.ts — mergeConfig 패턴

vite.config.ts를 직접 import하고 mergeConfig로 병합한다. Tailwind v4 플러그인은 vite.config에만 선언되어 있으므로, vitest 전용 config를 별도로 작성하면 플러그인이 누락되어 테스트 환경에서 CSS 처리가 깨진다.

// vitest.config.ts
import { mergeConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      environment: 'happy-dom',
      setupFiles: ['./src/test/setupTests.ts'],
      globals: false,           // 명시적 import 권장 (IDE 자동완성·타입 안전성)
    },
  })
);

2. setupTests.ts — happy-dom + Radix UI stub

@testing-library/jest-dom/vitest 서브패키지를 사용한다. 표준 경로(@testing-library/jest-dom)는 vitest와 함께 사용 시 v6+에서 ReferenceError: expect is not defined가 발생한다.

Radix UI는 hasPointerCapture, releasePointerCapture, scrollIntoView 3개 API를 사용하는데 happy-dom 15+에서 미구현 상태이므로 stub이 필요하다.

// src/test/setupTests.ts
import '@testing-library/jest-dom/vitest';

// happy-dom 15+ 미구현 Radix UI API stub
if (!Element.prototype.hasPointerCapture) {
  Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.releasePointerCapture) {
  Element.prototype.releasePointerCapture = () => {};
}
if (!Element.prototype.scrollIntoView) {
  Element.prototype.scrollIntoView = () => {};
}

3. TanStack Query 훅 테스트 격리 wrapper

훅 테스트는 매 호출마다 새 QueryClient 인스턴스를 만들어야 테스트 간 캐시가 오염되지 않는다. retry: false, staleTime: 0, gcTime: 0 3개 옵션이 없으면 재시도·캐시 동작이 테스트를 불안정하게 만든다.

// src/test/queryClientWrapper.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false, staleTime: 0, gcTime: 0 },
    },
  });
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

사용법:

const { result } = renderHook(() => useMyHook(), { wrapper: createWrapper() });

API 모킹은 MSW 없이 vi.mock('@/services/api-client')로 충분하다.

4. 패키지 설치

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom happy-dom

왜 중요한가

  • mergeConfig 없이 vitest 전용 config를 작성하면 Tailwind v4 플러그인이 누락되어 테스트 환경에서 @import "tailwindcss" 처리가 실패하거나 경고가 쌓인다
  • @testing-library/jest-dom(표준 경로) 사용 시 vitest에서 expect.extend가 이미 초기화되지 않아 런타임 에러가 발생한다 — 서브패키지 경로가 필수
  • Radix UI stub 3개를 누락하면 Dialog, Select, Popover 등 Radix 컴포넌트가 포함된 모든 테스트에서 TypeError가 발생한다
  • TanStack Query wrapper에서 retry: false가 없으면 실패한 쿼리가 3회 재시도하며 테스트가 느려지고 waitFor 타임아웃이 발생한다

5. globals: false + @testing-library/react 자동 cleanup 비활성 함정 (9-D1에서 실증)

메커니즘

vitest.config.ts에 globals: false(명시적 import 모드)를 설정하면 @testing-library/react의 자동 cleanup이 비활성화된다.

RTL은 afterEach(cleanup)을 자동 등록할 때 globalThis.afterEach가 존재하는지 확인한다. globals: false에서는 afterEach가 전역으로 노출되지 않으므로, RTL이 자동 등록을 건너뛴다. 결과적으로 각 테스트가 끝난 뒤 DOM이 초기화되지 않아 이전 테스트 DOM이 누적된다.

증상

  • globals: false 환경에서 같은 파일에 describe 블록이 2개 이상 있을 때 12개 테스트 실패
  • 에러 메시지: Found multiple elements with the role "button" and name "이전" (이전 테스트 DOM 잔존)
  • 단일 describe 파일에서는 증상이 없음 — 우연히 통과

기존 4개 파일이 우연히 통과한 이유

기존 18개 테스트 파일(ConfirmDialog.test.tsx, AssignBatchModal.test.tsx 등)은 파일당 단일 describe 블록으로 작성되어 있었다. 단일 describe 안에서는 테스트가 순서대로 실행되며 DOM 누적이 드러나지 않는다. → Pagination.test.tsx가 2개 describe(compact / full)를 가지는 첫 파일로 실패를 실증했다.

해결

모든 테스트 파일 최상단에 수동 cleanup을 추가한다:

import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(cleanup);

기존 4개 파일도 소급 추가 (9-D1 evaluator 2차 리뷰에서 지적 → 전면 추가): - ConfirmDialog.test.tsx - AssignBatchModal.test.tsx - AssignBatchModal.handlers.test.tsx - useBatchLogs.test.ts

적용 커밋

e827b9a (Phase 9-D1)

적용 시기

  • Vite + Tailwind v4 + shadcn/ui 스택에 vitest를 처음 도입할 때 → 이 파일의 설정을 그대로 복사
  • TanStack Query 훅 테스트를 작성할 때 → createWrapper()를 src/test/queryClientWrapper.tsx에서 import
  • happy-dom에서 Radix 컴포넌트 테스트 시 stub이 없어 TypeError가 발생하면 → setupTests.ts 확인
  • globals: false를 유지하는 이유: describe, it, expect, vi를 명시적으로 import해야 IDE 자동완성과 TypeScript 타입 추론이 정확하게 동작한다

관련 자료