Advanced (업데이트: 2026. 6. 3.)

Claude Code로 Vitest 고급 테스트 구현하기

Claude Code로 Vitest 모의 객체, 가짜 타이머, jsdom, 커버리지, 스냅샷, CI를 설계합니다.

Claude Code로 Vitest 고급 테스트 구현하기

이 Vitest 흐름이 해결하는 문제

Claude Code에 “Vitest 테스트를 추가해 줘”라고만 말하면 로컬에서는 통과하지만 시간, DOM, 외부 API, CI에서 흔들리는 테스트가 나오기 쉽습니다. 이 글에서는 모의 객체, 가짜 타이머, 커버리지, jsdom, 스냅샷, CI를 하나의 실무 흐름으로 묶습니다. 모의 객체는 실제 의존성 대신 쓰는 가짜 구현이고, 가짜 타이머는 테스트 안에서 시간을 멈추거나 진행시키는 시계이며, 커버리지는 아직 검증되지 않은 코드 경로를 찾는 지표입니다.

2026년 6월 3일 기준으로 Vitest 공식 문서의 Getting Started, Mocking, Timers, Dates, Test Environment, Coverage, Snapshot, CLI를 확인했습니다. 공식 문서는 Vitest 4 계열과 Node 20 이상을 전제로 설명하며, 감시 모드와 vitest run을 구분합니다. CI에서는 한 번 실행하고 종료되는 명령을 써야 합니다.

Claude Code에는 “무엇을 테스트할지”보다 “어디를 가짜로 둘지, 시간을 고정할지, jsdom으로 충분한지, 어떤 명령으로 검증할지”를 함께 전달해야 합니다. 관련 흐름은 Claude Code 테스트 전략, MSW API 모의 처리 가이드, Playwright E2E 테스트도 참고하세요.

flowchart TD
  A["명세: 성공 조건과 실패 조건"] --> B["Vitest config: node/jsdom/coverage"]
  B --> C["단위 테스트: 순수 로직과 API 경계"]
  B --> D["시간 테스트: 가짜 타이머와 고정 Date"]
  B --> E["DOM 테스트: jsdom과 스냅샷"]
  C --> F["CI: vitest run --coverage"]
  D --> F
  E --> F

먼저 안정적인 설정을 만든다

Vitest, V8 커버리지 제공자, jsdom, TypeScript를 설치합니다. Vite 앱에서는 기존 설정을 공유할 수 있지만, 테스트 의도를 분명히 하려면 vitest.config.ts를 따로 두는 편이 좋습니다.

npm install -D vitest @vitest/coverage-v8 jsdom typescript
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "coverage": "vitest run --coverage"
  }
}
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    globals: false,
    restoreMocks: true,
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: ["src/**/*.d.ts", "src/**/*.test.{ts,tsx}", "src/test/**"],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
});

globals: falsedescribeexpect가 어디에서 오는지 명확히 보여 줍니다. restoreMocks: true는 모의 구현 누수를 줄이지만, 가짜 타이머 복구와 DOM 정리는 별도로 해야 합니다.

사례1: API 경계를 모의 처리한다

단위 테스트에서 주문, 결제, 사용자 API를 실제로 호출하면 느리고 실패 원인도 흐려집니다. 여기서는 요청 경로, 요청 본문, 입력 검증, 오류 변환만 검증합니다.

// src/orders.ts
export type ApiClient = {
  post<T>(path: string, body: unknown): Promise<T>;
};

export class OrderError extends Error {
  constructor(message = "Order request failed") {
    super(message);
    this.name = "OrderError";
  }
}

type OrderInput = {
  sku: string;
  quantity: number;
};

type OrderResponse = {
  id: string;
  status: "accepted" | "queued";
};

export async function createOrder(api: ApiClient, input: OrderInput) {
  if (input.quantity < 1) {
    throw new OrderError("Quantity must be at least 1");
  }

  try {
    return await api.post<OrderResponse>("/orders", input);
  } catch {
    throw new OrderError("Order API failed");
  }
}
// src/orders.test.ts
import { describe, expect, it, vi } from "vitest";
import { createOrder, type ApiClient, OrderError } from "./orders";

describe("createOrder", () => {
  it("posts the order payload to the API", async () => {
    const api: ApiClient = {
      post: vi.fn().mockResolvedValue({ id: "ord_1", status: "accepted" }),
    };

    await expect(createOrder(api, { sku: "book-1", quantity: 2 })).resolves.toEqual({
      id: "ord_1",
      status: "accepted",
    });
    expect(api.post).toHaveBeenCalledWith("/orders", { sku: "book-1", quantity: 2 });
  });

  it("rejects invalid quantity before calling the API", async () => {
    const api: ApiClient = { post: vi.fn() };

    await expect(createOrder(api, { sku: "book-1", quantity: 0 })).rejects.toBeInstanceOf(
      OrderError,
    );
    expect(api.post).not.toHaveBeenCalled();
  });

  it("wraps transport errors in a domain error", async () => {
    const api: ApiClient = {
      post: vi.fn().mockRejectedValue(new Error("ECONNRESET")),
    };

    await expect(createOrder(api, { sku: "book-1", quantity: 1 })).rejects.toThrow(
      "Order API failed",
    );
  });
});

이 방식은 전체 모듈 모의 처리보다 읽기 쉽습니다. vi.mock()도 유용하지만 import보다 먼저 끌어올려지므로 초기화 순서를 잘못 쓰면 테스트가 헷갈립니다. 가능하면 작은 vi.fn()으로 필요한 행동만 증명하세요.

사례2: 가짜 타이머로 기한 처리를 고정한다

구독 체험, 재시도, 알림, 디바운스는 실제 시간을 기다리면 불안정합니다. Vitest의 가짜 타이머는 setTimeout, setInterval, 시스템 날짜를 제어합니다.

// src/trial.ts
const DAY_MS = 24 * 60 * 60 * 1000;

export function getTrialEndsAt(days = 7) {
  return new Date(Date.now() + days * DAY_MS).toISOString();
}

export function scheduleTrialReminder(send: () => void, days = 7) {
  return setTimeout(send, days * DAY_MS);
}
// src/trial.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getTrialEndsAt, scheduleTrialReminder } from "./trial";

describe("trial reminder", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-06-03T00:00:00.000Z"));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("calculates the trial end date from the fixed clock", () => {
    expect(getTrialEndsAt()).toBe("2026-06-10T00:00:00.000Z");
  });

  it("runs the reminder after the configured number of days", () => {
    const send = vi.fn();
    const timer = scheduleTrialReminder(send, 3);

    vi.advanceTimersByTime(3 * 24 * 60 * 60 * 1000 - 1);
    expect(send).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);
    expect(send).toHaveBeenCalledTimes(1);
    clearTimeout(timer);
  });
});

가장 흔한 함정은 vi.useRealTimers()를 빠뜨리는 것입니다. 이전 테스트의 가짜 시계가 남으면 다른 파일이 갑자기 실패합니다. Promise가 섞인 경우에는 반드시 await를 사용하세요. 날짜와 시간대 경계는 Claude Code 날짜와 시간 처리 가이드에서 더 다룹니다.

사례3: jsdom과 스냅샷으로 표시 계약을 지킨다

jsdom은 Node 안에서 브라우저 DOM API를 흉내 내는 환경입니다. DOM 구조, 텍스트, 접근성 속성에는 적합하지만 레이아웃, 실제 초점 이동, Canvas, 시각 회귀에는 충분하지 않습니다.

// src/notice.ts
export function renderNotice(target: HTMLElement, message: string) {
  target.innerHTML = "";

  const notice = document.createElement("p");
  notice.setAttribute("role", "status");
  notice.dataset.testid = "notice";
  notice.textContent = message;

  target.append(notice);
  return notice;
}
// src/notice.test.ts
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { renderNotice } from "./notice";

afterEach(() => {
  document.body.innerHTML = "";
});

describe("renderNotice", () => {
  it("renders an accessible status message", () => {
    document.body.innerHTML = '<div id="app"></div>';
    const target = document.querySelector<HTMLDivElement>("#app");
    if (!target) throw new Error("missing #app");

    const notice = renderNotice(target, "저장했습니다");

    expect(notice.getAttribute("role")).toBe("status");
    expect(notice.textContent).toBe("저장했습니다");
    expect({
      html: document.body.innerHTML,
      text: notice.textContent,
    }).toMatchInlineSnapshot(`
      {
        "html": "<div id=\\"app\\"><p role=\\"status\\" data-testid=\\"notice\\">저장했습니다</p></div>",
        "text": "저장했습니다",
      }
    `);
  });
});

스냅샷은 작을수록 좋습니다. 페이지 전체나 클래스가 많은 컴포넌트를 통째로 저장하면 리뷰가 노이즈로 가득 찹니다. 중요한 속성은 직접 단언하고, 작은 구조만 스냅샷으로 남기세요.

커버리지와 CI를 품질 관문으로 만든다

커버리지는 숫자를 올리기 위한 장식이 아니라 테스트되지 않은 분기를 찾는 도구입니다. Vitest 문서는 V8과 Istanbul 커버리지 제공자를 설명하며, 기본 제공자는 V8입니다. coverage.include를 명시하지 않으면 테스트에서 import되지 않은 새 파일이 보고서에 나타나지 않을 수 있습니다.

# .github/workflows/vitest.yml
name: vitest

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test:run
      - run: npm run coverage

CI에서는 vitest run을 사용하세요. vitest만 쓰면 환경에 따라 감시 모드로 들어가 작업이 끝나지 않을 수 있습니다. 전체 파이프라인 설계는 Claude Code CI/CD 설정 가이드를 참고하세요.

Claude Code에 줄 프롬프트

src/orders.ts에 Vitest 테스트를 추가해 주세요.
createOrder만 테스트합니다.
외부 API는 vi.fn()으로 모의 처리하고 실제 HTTP는 호출하지 마세요.
성공, 잘못된 입력, 전송 실패 세 가지 사례를 반드시 포함하세요.
코드가 요구하지 않는 한 가짜 타이머나 jsdom은 쓰지 마세요.
수정 후 npm run test:run 실행을 전제로 한 확인 방법과 남은 위험을 짧게 보고하세요.

범위, 가짜 경계, 실패 사례, 검증 명령을 함께 주면 Claude Code가 성공 사례 하나에서 멈추지 않습니다. 같은 규칙은 CLAUDE.md 모범 사례에도 넣어 두면 좋습니다.

자주 나는 실패

실패증상해결
모의 객체를 되돌리지 않음호출 횟수나 가짜 구현이 다음 테스트로 새어 나감restoreMocks, vi.clearAllMocks(), vi.restoreAllMocks()를 목적에 맞게 사용
가짜 타이머를 복구하지 않음다른 파일의 시간 테스트가 불규칙하게 실패afterEach에서 vi.useRealTimers() 호출
jsdom을 실제 브라우저로 착각CSS, 레이아웃, 이미지, Canvas 결과가 다름DOM 계약은 Vitest, 브라우저 동작은 Playwright로 분리
스냅샷이 너무 큼리뷰 차이가 소음으로 변함작은 구조만 스냅샷으로 저장
coverage.include가 없음테스트되지 않은 파일이 보이지 않음src/**/*.{ts,tsx}를 명시
비동기를 기다리지 않음실패해야 할 코드가 통과한 것처럼 보임await expect(promise).resolves 또는 rejects 사용
CI가 감시 모드로 실행됨작업이 끝나지 않음vitest run 또는 vitest related --run 사용

이 흐름을 팀 저장소에 맞추고 싶다면 ClaudeCodeLab의 영문 교육 페이지실무 템플릿에서 테스트 표준, 리뷰 프롬프트, CI 관문을 확인할 수 있습니다.

실제 확인 결과

최종 결과는 세 가지 복사 가능한 Vitest 사례입니다. API 경계 테스트, 고정 시간 테스트, jsdom 렌더링 테스트와 작은 스냅샷을 각각 독립 파일로 구성했습니다. 게시 전에는 Vitest 공식 문서, 내부 링크, 외부 링크, 코드 울타리, updatedDate, 커버리지 설정, CI에서 vitest run을 사용하는 점을 다시 확인했습니다.

#Claude Code #Vitest #테스트 #TypeScript #품질 보증
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.