Advanced (업데이트: 2026. 7. 22.)

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

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

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

이 Vitest 흐름이 해결하는 문제

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

2026년 7월 22일에 이 글을 공식 Vitest CLI 가이드, watch 설정, Vitest 4 마이그레이션 가이드와 대조했습니다. Vitest 4는 Vite 6 이상과 Node 20 이상이 필요합니다. CI 또는 비대화형 터미널에서는 vitest도 자동으로 한 번 실행되며, vitest run을 명시하면 스크립트 의도가 더 분명합니다.

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는 CI가 false이고 터미널이 대화형일 때만 감시하며, CI나 비대화형 환경에서는 한 번 실행됩니다. lint-staged 같은 대화형 도구에서는 vitest related --run을 사용할 수 있습니다. 전체 파이프라인 설계는 Claude Code CI/CD 설정 가이드를 참고하세요.

Claude Code에 줄 프롬프트

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

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

세 가지 사용 사례

사용 사례 1은 주문 또는 가입 폼입니다. 잘못된 요청 본문은 구매나 가입 완료를 막을 수 있습니다. Claude Code에는 vi.fn() API 경계 테스트, 성공, 잘못된 입력, 전송 실패 사례 작성을 맡기고, 결제, 청구, 재고, 고객 데이터 규칙은 사람이 승인합니다.

사용 사례 2는 체험판, 예약, 갱신 알림입니다. Claude Code는 Date를 고정하고 가짜 타이머를 진행해 콜백이 한 번만 실행되는지 확인할 수 있습니다. 시간대, 영업일, 이메일 또는 문자 발송, 과금에 영향을 주는 판단은 사람이 검토합니다.

사용 사례 3은 저장 알림, 검증 메시지, 관리자 상태 배너 같은 작은 DOM 계약입니다. Claude Code는 jsdom으로 텍스트, 역할 속성, 작은 스냅샷을 확인할 수 있습니다. 실제 브라우저 동작, 포커스 이동, 시각 회귀, 고객에게 보이는 문구는 사람이 승인합니다.

Claude Code가 할 일과 사람이 승인할 일

영역Claude Code에 맡길 일사람의 승인이 필요한 일
테스트 코드모의 객체, 가짜 타이머, jsdom 단언, 커버리지 명령 초안단언이 제품 동작을 약하게 만들지 않았는지 확인
CIvitest run, vitest run --coverage 관문 제안필수 체크, 비용이 드는 runner, 프로덕션 배포 규칙
데이터ord_1, book-1 같은 합성 ID 사용고객 데이터, 비밀값, API 키, 결제 식별자 검토
브라우저 동작Vitest에서 DOM 계약 확인Playwright 또는 수동 검토로 실제 브라우저 흐름 확인

흔한 함정과 수정 방법

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

수정 목표는 단순히 초록색으로 만드는 것이 아닙니다. 테스트가 실패하면 전체 오류를 붙여 넣고, 단언을 지우지 말고 구현이나 모의 설정을 먼저 고치라고 지시하세요. 보안, 프로덕션, 과금, 고객 데이터 변경은 사람이 diff를 본 뒤 승인해야 합니다.

저장소에 같은 리뷰 규칙을 적용하려면 ClaudeCodeLab의 실무 템플릿에서 테스트 표준, 리뷰 프롬프트, CI 관문을 확인하세요.

실제 확인 결과

2026년 7월 22일에 TypeScript 예제를 임시 프로젝트로 추출하고 Node v24.14.1과 Vitest 4.1.10으로 실행했습니다. vitest runvitest run --coverage에서 API 경계, 모듈 mock, 고정 시각, 타이머 경계, DOM 속성, 인라인 스냅샷을 다룬 4개 파일과 7개 테스트가 모두 통과했습니다. 커버리지는 statements 94.73%, branches 100%, functions 85.71%, lines 94.73%였습니다. Mailer는 mock 처리해 실제 이메일이나 프로덕션 API를 호출하지 않았습니다.

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

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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