Advanced (更新: 2026/6/7)

Vitestのモックとカバレッジで、Claude Codeのテストを“落ちない”状態にする

Claude Codeが書いたVitestのテストがCIで落ちる。原因はモックの巻き上げとタイマーの後始末。vi.mockの使い方、カバレッジ計測、落ちたテストを直させる手順を実例で。

Vitestのモックとカバレッジで、Claude Codeのテストを“落ちない”状態にする

「Vitestのテスト書いといて」とClaude Codeに頼んだら、緑のチェックがずらっと並びました。やったね、と思ってCIにpushした。落ちました。

ローカルでは通るのに、CIでだけ赤くなる。しかも、さっきまで関係なかったはずのファイルのテストまで連鎖して落ちている。ログを見ても「Timeout」としか書いていない。心当たりがなさすぎて、コーヒーが冷めました。

原因はだいたい2つに集約されます。モックの後始末をしていないか、偽のタイマーを本物に戻し忘れているか。どちらも「テスト単体では通るけど、まとめて走らせると壊れる」という、いちばんタチの悪い壊れ方をします。

この記事は、Claude Codeにテストを書かせたあとに僕が踏んだ地雷と、その埋め方の記録です。vi.mock でのモック、vi.fn() の使い分け、カバレッジの読み方、そして落ちたテストをClaude Codeに直させる手順。単体テストと結合テストに絞ります。ブラウザ実機のE2Eは扱いません。そっちはPlaywright E2Eテスト実践ガイドに分けてあります。

この記事の要点

  • Claude Codeのテストがフレーキー(たまに落ちる)になる主犯は、モックとタイマーの後始末忘れvitest.config.tsrestoreMocks: trueafterEach で大半は消える。
  • vi.mock() は書いた場所に関係なくファイル先頭に巻き上げられる。モジュールごと差し替えるならこれ、依存を1個だけ偽物にするなら vi.fn() を引数で渡すほうが事故が少ない。
  • カバレッジは数字を上げるゲームではなく、テストし忘れた分岐を探すレーダーcoverage.include を書かないと未テストの新規ファイルが見えない。
  • Claude Codeには「成功条件」だけでなく「失敗条件」と「実行コマンド」をセットで渡す。これだけで実務で踏む穴が埋まる。
  • CIでは vitest ではなく vitest run を使う。これを忘れると監視モードに入ってジョブが永遠に終わらない。

2026年6月時点で、Vitestの最新メジャーは4系です(Vitest公式ガイドで確認)。Vite 6以上、Node 20以上が前提になっています。以下のコードもこの前提で書いています。

なぜ「緑なのに落ちる」が起きるのか

テストが状態を持つから、です。

イメージとしては、共用キッチンで料理するのに似ています。前の人が使ったフライパンを洗わずに次の人が使うと、味が混ざる。テストも同じで、あるテストが偽物に差し替えた関数や、止めた時計を、そのまま放置すると、次のテストがその汚れた状態を引き継いでしまう。

しかもVitestは速さのためにテストをまとめて走らせます。ファイル内のテストは原則上から順ですが、後始末を1か所サボると、実行順が変わった瞬間に化ける。ローカルとCIでファイルの読み込み順が違って、CIでだけ落ちる、という現象の正体はだいたいこれです。

Claude Codeは1個のテストを書くのはうまい。でも「他のテストと一緒に走ったときの汚れ」までは、指示しないと面倒を見てくれません。だから設定とプロンプトで、後始末を強制する必要があります。

まず設定で“掃除”を自動化する

地雷の半分は、最初の設定で踏まなくなります。依存を入れて、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 を書かないと、importされた行しか集計されない
      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 をファイル冒頭で import する形にしておくと、Claude Codeがテストを別ファイルにコピーしたとき「expect が未定義」みたいな事故が減るんです。グローバルに頼ると、移植先で静かに壊れます。

restoreMocks: true は掃除当番の自動化です。ただし、これが面倒を見てくれるのは vi.spyOnvi.fn の実装まで。偽タイマーやDOMの掃除は別腹なので、そこは afterEach で明示します。ここを「設定で全部やってくれる」と勘違いすると、また緑なのに落ちます。

vi.mock と vi.fn、どっちを使うか問題

モックには大きく2つの道具があります。ここの使い分けが、上級者とそうでない人の分かれ目です。

やりたいこと使う道具向いている場面
依存を1個だけ偽物にするvi.fn() を引数で渡す関数に依存を外から注入できる設計
モジュールまるごと差し替えるvi.mock("./mod")importを書き換えられない、深い依存
実物は活かしつつ呼び出しを記録vi.spyOn(obj, "m")ログ出力の確認など、副作用だけ見たい

僕の結論を先に言うと、迷ったら vi.fn() を引数で渡すです。vi.mock は強力ですが、後述する「巻き上げ」のクセで初心者が必ず一度はハマります。まずは依存を引数で受け取れる設計にして、テストでは偽物の関数を渡すだけ。これがいちばん読みやすく、落ちたときの原因も短く出ます。

API境界を vi.fn() で固める

外部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");
  }
}

テスト側では、ApiClient の偽物を vi.fn() で作って渡します。これがコピペで動く最小セットです。package.jsonscripts を入れたうえで npm run test:run を実行してください。

// src/orders.test.ts
import { describe, expect, it, vi } from "vitest";
import { createOrder, type ApiClient, OrderError } from "./orders";

describe("createOrder", () => {
  it("正常系: 注文bodyを正しいパスへ送る", async () => {
    // post を偽物にして、成功レスポンスを返させる
    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("異常系: 数量0ならAPIを呼ぶ前に弾く", async () => {
    const api: ApiClient = { post: vi.fn() };

    await expect(
      createOrder(api, { sku: "book-1", quantity: 0 }),
    ).rejects.toBeInstanceOf(OrderError);
    // バリデーションで止まっているので post は呼ばれない
    expect(api.post).not.toHaveBeenCalled();
  });

  it("異常系: 通信エラーをドメインのエラーに包む", 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",
    );
  });
});

成功・入力不正・通信失敗の3点セットになっているのが大事です。Claude Codeには「この3ケースを必ず含めて」と指示すれば、抜けが減ります。api.post が呼ばれた/呼ばれなかったを検証しているので、バリデーションのすり抜けにも気づけます。

vi.mock の「巻き上げ」が初心者を殺す

依存を引数で渡せないとき、たとえば import { sendMail } from "./mailer" のように直接importしている関数を偽物にしたい場合は vi.mock の出番です。ここで全員が一度はハマります。

vi.mock() は、ファイルのどこに書いてもいちばん上に巻き上げられて実行されます(これはVitest公式のMockingガイドに明記されています)。つまり、ファイルの途中で定義した変数をモックの中で使おうとすると「まだ存在しない」と言われて落ちます。

// src/notify.test.ts
import { describe, expect, it, vi } from "vitest";
import { notifyUser } from "./notify";
import { sendMail } from "./mailer";

// この vi.mock は import より前に巻き上げられて実行される
vi.mock("./mailer", () => ({
  sendMail: vi.fn().mockResolvedValue({ ok: true }),
}));

describe("notifyUser", () => {
  it("メール送信を1回だけ呼ぶ", async () => {
    await notifyUser("[email protected]");

    expect(sendMail).toHaveBeenCalledTimes(1);
    expect(sendMail).toHaveBeenCalledWith("[email protected]");
  });
});

巻き上げのせいで、vi.mock のファクトリ関数の中では「外で定義した普通の変数」を参照できません。どうしても外の値を使いたいときは vi.hoisted() でその値も一緒に巻き上げる、という回避策があります。でも正直、ここまで来たら一歩引いて「そもそも依存を引数で渡せる設計にできないか」を考えたほうが早いことが多いです。モックは病気を治す薬ではなく、痛み止めだと思っています。

カバレッジは“点数”ではなく“レーダー”として読む

カバレッジ80%、と言われると合格点っぽく聞こえます。でも僕はこの数字を成績表として見るのをやめました。

カバレッジの本当の使い道は、自分がテストし忘れた分岐を炙り出すレーダーです。npm run coverage を走らせてHTMLレポートを開くと、通っていない行が赤くハイライトされます。そこを見て「あ、エラー時の分岐、テストしてないわ」と気づく。数字を80%に上げるためにどうでもいいテストを足すのは本末転倒です。

地雷が1つあります。coverage.include を書かないと、テストでimportされたファイルしか集計されません。新しく作ったけどまだ誰もテストしていないファイルは、レポートに「そもそも存在しないもの」として扱われ、カバレッジ100%なのに穴だらけ、という嘘の安心が生まれます。上の vitest.config.tsinclude: ["src/**/*.{ts,tsx}"] を明示しているのはこのためです。

しきい値(thresholds)を設定しておけば、カバレッジが基準を割った瞬間に vitest run --coverage が失敗して止まります。これをCIに入れると、テストを書かずに機能だけ足すPRに自動でブレーキがかかります。

watch と UI で“書きながら”回す

開発中は監視モード(watch)が手放せません。npm test(=vitest)で起動すると、保存するたびに関連テストだけが自動で走ります。全部走らせ直さないので速い。

もうひとつ、vitest --ui でブラウザにテスト結果のダッシュボードが出ます。どのテストが落ちたか、どのアサーションでこけたかが視覚的に見えるので、Claude Codeに直させるときの「どこが赤いか」の共有が楽になります。

# 開発中: 保存するたびに関連テストを自動実行
npm test

# ブラウザでテスト結果を見ながら回す
npx vitest --ui

注意は1点だけ。CIで vitest をそのまま打つと監視モードに入り、ジョブが終わりません。CIでは必ず vitest run(または --run)を使う。ここを外すと、GitHub Actionsのジョブが10分タイムアウトまで回り続けて課金だけ食う、という悲しい事故になります。CI全体の組み方はClaude Code CI/CDセットアップガイドにまとめてあります。

# .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

落ちたテストをClaude Codeに直させる手順

ここが「advanced」の本題です。Claude Codeにテストを書かせて落ちたとき、雑に「直して」と言うと、たいていテストのほうを甘くして緑にするという最悪の直し方をされます。アサーションを消したり、expect を緩めたり。それは直したんじゃなくて、見て見ぬふりです。

僕がたどり着いた手順はこうです。

  1. エラーの全文をそのまま貼る。 「Timeout」だけでなく、スタックトレースと、どのテストファイルのどの it で落ちたかまで渡す。vitest --ui のスクショでもいい。
  2. 「テストを緩めるな」と先に釘を刺す。 「アサーションを削らず、実装かモックの設定で直して」と明示する。
  3. 原因の仮説を1つ添える。 「他のテストと一緒に走ると落ちるなら後始末を疑って」のように方向を示すと、当てずっぽうの修正が減る。
  4. 直したあと npm run test:run を全件通すまで確認させる。 単体ではなく全件。フレーキーは単体だと再現しないからです。

実際にClaude Codeへ渡すプロンプトの型がこれです。コピペして使えます。

Vitestで src/orders.ts のテストを追加してください。
対象は createOrder のみ。外部APIは vi.fn() でモックし、本物のHTTPは呼ばないでください。
成功・入力不正・通信失敗の3ケースを必ず含めてください。
偽タイマーやjsdomが不要なら使わないでください。
テストが落ちても、アサーションを削って緑にするのは禁止です。
実装かモック設定の側で直してください。
最後に npm run test:run を全件通した想定の手順と、残るリスクを3行で報告してください。

この粒度で渡すと、Claude Codeが勝手にE2Eへ手を広げたり、設定を書き換えたりしにくくなります。チームで使うなら、CLAUDE.mdベストプラクティスに「テストを緩めて緑にするのは禁止」「外部サービスは境界を明示してモックする」「CIは vitest run」と書いておくと、毎回指示しなくても再現します。HTTPをもっと本物に近づけてモックしたいならMSWモック活用ガイド、テスト対象のAPI設計そのものはClaude Code API開発ガイドが参考になります。

僕がやらかした失敗3つ

正直に書きます。最初に量産したテストは、3週間で3回CIを赤く染めました。

ひとつ目は、偽タイマーを戻し忘れたこと。リマインダーのテストで vi.useFakeTimers() を使ったのに、afterEachvi.useRealTimers() を呼んでいなかった。すると、まったく無関係な別ファイルの時刻テストが「たまに」落ちる。原因を探して半日溶かしました。時計を止めたら必ず元に戻す。これは料理のあとに火を消すのと同じで、例外なくやるべきでした。

ふたつ目は、モックを盛りすぎたこと。1つのテストで関連モジュールを5個も vi.mock で偽物にしたら、もはや何をテストしているのか自分でも分からなくなりました。テストが落ちても、5個のうちどの偽物がおかしいのか切り分けられない。今は「偽物にするのは1テストにつき境界1つ」を目安にしています。

みっつ目は、スナップショットを大きく取りすぎたこと。HTML全体を toMatchSnapshot に放り込んだら、関係ないclass名を1個変えただけでスナップショットが差分だらけになり、レビューがノイズの海になりました。今は「壊れたら困る小さな構造」だけをスナップショットにして、重要な属性は普通の expect で確認しています。

よくある質問

Q. vi.mockvi.fn() はどう使い分ければいいですか。 A. 依存を関数の引数で渡せる設計なら vi.fn() を渡すのが第一選択です。読みやすく、落ちたときの原因も短く出ます。importを直接書き換えられない深い依存だけ vi.mock を使ってください。

Q. restoreMocks: true を設定すれば後始末は全部終わりますか。 A. いいえ。それが面倒を見るのは vi.spyOn / vi.fn の実装までです。偽タイマー(vi.useFakeTimers)とDOMの掃除(document.body.innerHTML = "")は afterEach で別途やる必要があります。ここの勘違いがフレーキーの定番です。

Q. カバレッジは何%を目指せばいいですか。 A. 数字そのものを目標にしないでください。レポートの赤い行を見て「このエラー分岐、テストしてないな」と気づくための道具です。あえて基準を引くなら lines 80% / branches 75% あたりから始めて、レーダーとして使うのがおすすめです。

Q. ローカルでは通るのにCIだけ落ちます。何を疑えばいいですか。 A. まず後始末忘れ(タイマー・モック)、次にCIで vitest(監視モード)を打っていないか、最後にNodeのバージョン差です。vitest run を使い、afterEach の掃除を確認してください。

Q. E2Eテストはこの構成に混ぜていいですか。 A. 分けたほうが幸せです。jsdomはDOMの構造確認まで、実ブラウザのクリックや見た目の崩れはPlaywright E2Eテスト実践ガイドへ。単体・結合とE2Eを同じファイルに混ぜると、遅さと不安定さが伝染します。

まとめ:実際に試した結果

冒頭の「緑なのにCIで落ちる」事件以来、僕はテストを書いたあとに必ず2つだけ確認するようになりました。afterEach で時計とモックを戻しているか。CIで vitest run を使っているか。この2つを潰すだけで、フレーキーの体感9割が消えました。

ordersnotify の例は、それぞれ独立したファイルとしてコピペでき、TypeScriptとしても破綻しない形に整えてあります。いちばん効いたのは、Claude Codeへ渡すプロンプトに「テストを緩めて緑にするのは禁止」と一行入れたことでした。これだけで、アサーションをこっそり消す“なんちゃって修正”がほぼ止まった。賢いモデルに丸投げするより、後始末と禁止事項を先に決める。テストの安定は、その地味な土台で決まる、というのが今の実感です。

自分のリポジトリに合わせてこの型を入れたい人は、プロダクト一覧に教材をまとめてあります。

#Claude Code #Vitest #モック #カバレッジ #TypeScript
無料

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

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

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

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

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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