Advanced (更新: 2026/6/3)

Claude CodeでVitest上級テストを実装する実践ガイド

Claude CodeでVitestのモック、偽タイマー、カバレッジ、CIを安全に設計する実践ガイド。

Claude CodeでVitest上級テストを実装する実践ガイド

この記事で作るVitestの完成形

Claude Codeに「Vitestのテストを書いて」とだけ頼むと、見た目は通るけれど、時刻、DOM、外部API、CIで崩れるテストになりがちです。この記事では、モック、偽タイマー、カバレッジ、jsdom、スナップショット、CIを一つの小さな型にまとめます。モックは「本物のAPIや関数の代わりに置く偽物」、偽タイマーは「テスト中だけ時間を止めたり進めたりする時計」、カバレッジは「まだ検証されていないコード行を見つける計測」です。初めてVitestを触る人でも、どの問題にどの道具を使うか判断できるように書きます。

2026年6月3日時点の公式情報として、Vitest Getting StartedMockingTimersDatesTest EnvironmentCoverageSnapshotCLIを確認しました。Vitest 4系の公式ドキュメントでは、Vite 6以上とNode 20以上が前提で、vitest runは監視モードなしの一回実行として説明されています。CIではここを外さないことが大切です。

Claude Code側のコツは、テスト対象を広げすぎないことです。最初に「何を本物にし、何を偽物にするか」「時刻を固定するか」「DOMをjsdomで十分とみなすか」「カバレッジの失敗をCIで止めるか」を文章で渡します。APIの作り方はClaude Code API開発ガイド、MSWでHTTPを近く再現する方法はMSWモック活用ガイド、ブラウザ実機確認は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

最小構成を先に固定する

まず依存関係を入れます。既存のViteアプリなら設定を共用できますが、テスト専用のvitest.config.tsを置くと、Claude Codeにも人間にも意図が伝わります。

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: falseにしているのは、describeexpectの出所を明確にするためです。Jest風にグローバルで書くこともできますが、記事やチームのテンプレートではimportが見えるほうが、Claude Codeが別ファイルに移植するときの事故が減ります。restoreMocks: trueは、スパイやモックの実装をテストごとに戻す保険です。ただし、偽タイマーやDOMの掃除は別なので、後の例ではafterEachを明示します。

ユースケース1: API境界をモックで固める

外部APIを本当に叩くユニットテストは、遅く、失敗理由も読みにくくなります。注文作成のような処理では、APIクライアントそのものではなく「どのパスに、どのbodyを渡し、失敗時にどの例外へ変換するか」を確認します。

// 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",
    );
  });
});

この形なら、Claude Codeに「成功、入力不正、通信失敗の3ケースを増やして」と指示できます。vi.mock()でモジュール全体を差し替える方法もありますが、公式ドキュメントが注意している通り、vi.mockはimportより前に巻き上げられます。初心者が最初に詰まるのはここです。依存を引数で渡せるなら、上のような小さなvi.fn()のほうが読みやすく、テストの失敗理由も短くなります。

ユースケース2: 偽タイマーで期限処理を固定する

サブスクリプション、リマインダー、リトライ、トースト通知は、実時間を待つテストにするとすぐ不安定になります。Vitestはvi.useFakeTimers()setTimeoutsetIntervalを偽物にでき、vi.setSystemTime()Date.now()も固定できます。

// 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も必要です。setTimeoutの中でさらにPromiseを待つ実装では、vi.runAllTimersAsync()を使う候補になりますが、まずはテスト対象を小さくして、タイマーとPromiseを混ぜすぎない設計にします。日付とタイムゾーンの境界はClaude Codeで日付・時間処理を壊さない実装ガイドでも詳しく扱っています。

ユースケース3: jsdomとスナップショットで表示契約を守る

jsdomは、Node.js上でブラウザのDOM APIをまねる環境です。レイアウト、実際のフォーカス移動、Canvas、ブラウザ固有の挙動まで完全に再現するものではありません。ボタンのクリック、ARIA属性、テキストの出し分けなど「DOMの構造」を確認する用途に向いています。

// 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": "保存しました",
      }
    `);
  });
});

スナップショットは「期待する出力をファイルやテスト内に保存して差分を見る仕組み」です。便利ですが、巨大なHTML全体をスナップショットにすると、関係ないclass名変更だけでレビューが荒れます。上の例ではroletextを通常のexpectで確認し、スナップショットは小さな表示契約に限定しています。見た目の崩れや実ブラウザのイベントはPlaywrightに任せるほうが現実的です。

カバレッジとCIを品質ゲートにする

カバレッジは数字を上げるためではなく、未検証の分岐を見つけるために使います。Vitest公式ドキュメントでは、カバレッジ提供器としてv8istanbulが説明され、既定ではv8が使われます。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だけを実行すると、環境によって監視モードに入り、ジョブが終わらないことがあります。vitest runまたは--runを使うのが安全です。大きなリポジトリではvitest related --runやshardも検討できますが、まずは全体を一回通す小さなゲートを作ります。CI設計の全体像はClaude Code CI/CDセットアップガイドも参照してください。

Claude Codeに渡す実用プロンプト

Claude Codeへ依頼するときは、次のように「テストの目的」「偽物にする境界」「検証コマンド」を同時に渡します。

Vitestで src/orders.ts のテストを追加してください。
対象は createOrder だけです。
外部APIは vi.fn() でモックし、本物のHTTPは呼ばないでください。
成功、入力不正、通信失敗の3ケースを必ず含めてください。
偽タイマーやjsdomが不要なら使わないでください。
編集後に npm run test:run を想定した実行手順と、残るリスクを短く報告してください。

この粒度なら、Claude Codeは余計なE2Eテストや設定変更へ広がりにくくなります。チームで使うなら、CLAUDE.mdベストプラクティスに「テストは成功ケースだけで終わらせない」「外部サービスは境界を明示してモックする」「CIではvitest runを使う」と書いておくと再現性が上がります。

よくある失敗と直し方

失敗例症状直し方
モックを戻さない前のテストの呼び出し回数や実装が残るrestoreMocks: truevi.clearAllMocks()vi.restoreAllMocks()を目的別に使う
偽タイマーを戻さない別ファイルの時刻テストが突然落ちるafterEach(() => vi.useRealTimers())を必ず置く
jsdomを実ブラウザだと思うCSS、layout、画像、Canvasの挙動が本番と違うDOM契約はVitest、視覚と操作はPlaywrightへ分ける
スナップショットが大きすぎる差分レビューがノイズだらけになるスナップショットは小さな構造に絞り、重要属性は通常のexpectで確認する
coverage.includeがないimportされない未テストファイルが見えないinclude: ["src/**/*.{ts,tsx}"]を明示する
非同期をawaitしない本当は失敗している処理が通ったように見えるawait expect(promise).rejects...resolves...を使う
CIで監視モードになるジョブが終わらないvitest runvitest related --runを使う

特にAdSense向けの記事や販売教材に載せるコードでは、「コピペで動く」だけでは足りません。読者が失敗したときに、何を疑えばよいかまで書かれている必要があります。ClaudeCodeLabの教材やチーム研修でこの型を自分のリポジトリに合わせたい場合は、Claude Codeトレーニングプロダクト一覧から相談できます。

まとめ

Vitest上級テクニックは、派手なAPIを全部使うことではありません。API境界は小さなモックで切り、時刻は偽タイマーで固定し、DOMはjsdomで構造だけ確認し、スナップショットは小さく保ち、カバレッジとCIで未検証の分岐を見逃さないようにします。Claude Codeには「成功条件」だけでなく「失敗条件」と「実行コマンド」まで渡すと、実務で使えるテストに近づきます。

この記事で紹介した内容を実際に試した結果、orderstrialnoticeの例はそれぞれ独立したファイルとして貼り付けられる構成になり、TypeScript構文としても破綻しない形に整理できました。公開前の最終確認では、公式ドキュメントとの用語差、updatedDate、内部リンク、外部リンク、コードフェンス、カバレッジ設定、CIでvitest runを使う点を見直しました。特に効果があったのは、成功ケースより先に失敗ケースを表にしてからClaude Codeへ渡すことです。これだけで、認証失敗、期限切れ、非同期エラー、CI停止のような読者が実務で踏みやすい穴を記事内に残しやすくなりました。

#Claude Code #Vitest #テスト #TypeScript #品質保証
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。