コンテンツへスキップ
·13分で読めます

Vitestテストガイド:ゼロからプロダクション品質の信頼性へ

ユニットテスト、統合テスト、Testing Libraryによるコンポーネントテスト、モック戦略、カバレッジ設定、そして実際により良いソフトウェアを生むテスト哲学。

シェア:X / TwitterLinkedIn

多くのチームがテストを書く姿勢は、多くの人が運動する姿勢と似ている。やるべきだとわかっている、やらないと罪悪感を覚える、そしていざ始めると初日から飛ばしすぎて金曜日には挫折する。コードベースには、CSSクラスを変えるたびに壊れる浅いスナップショットテストがまばらに散り、誰も信用していない統合テストがいくつかあり、READMEのカバレッジバッジは嘘をついている。

私は両方の立場を経験してきた。テストゼロでプロジェクトを出荷し、デプロイのたびに冷や汗をかいたこともある。100%カバレッジを追いかけるチームにいて、機能を書くよりテストの保守に時間をかけていたこともある。どちらもうまくいかない。うまくいくのは、適切なテストを、適切な場所で、適切なツールを使って書くことだ。

VitestはJavaScriptのテストに対する私の考え方を変えた。新しい概念を発明したからではない — 基本原則はKent Beckが数十年前に書いた頃から変わっていない。テストを書くことが面倒な雑用ではなく開発ループの一部に感じられるほど、十分な摩擦を取り除いてくれたからだ。テストランナーが開発サーバーと同じ速さで、同じ設定を使うとき、言い訳は消えてなくなる。

この記事は、初期セットアップからすべてを価値あるものにする哲学まで、Vitestでのテストについて私が知っていることのすべてだ。

なぜJestではなくVitestなのか#

Jestを使ったことがあれば、VitestのAPIのほとんどはすでに知っているはずだ。これは設計上の意図であり、VitestはAPIレベルでJestと互換性がある。describeitexpectbeforeEachvi.fn() — すべて動く。では、なぜ乗り換えるのか?

ネイティブESMサポート#

JestはCommonJS向けに構築された。ESMも扱えるが、設定、実験的フラグ、そして時にはお祈りが必要だ。import/export構文(モダンなものはすべてこれ)を使っているなら、Jestのトランスフォームパイプラインと格闘したことがあるだろう。

VitestはVite上で動く。ViteはネイティブにESMを理解する。ソースコードのトランスフォームステップは不要で、そのまま動く。これは思った以上に重要だ。これまでデバッグしてきたJest関連の問題の半分は、モジュール解決に起因していた。SyntaxError: Cannot use import statement outside a moduleとか、モジュールが別のフォーマットでキャッシュされていたためにモックが機能しないとか。

開発サーバーと同じ設定#

プロジェクトがViteを使っているなら(2026年にReact、Vue、またはSvelteアプリを構築しているなら、おそらくそうだ)、Vitestは自動的にvite.config.tsを読み込む。パスエイリアス、プラグイン、環境変数が追加設定なしにテストで動く。Jestの場合、バンドラーのセットアップと同期を保たなければならない並行設定を保守する必要がある。vite.config.tsにパスエイリアスを追加するたびに、jest.config.tsに対応するmoduleNameMapperを追加することを忘れてはいけない。小さなことだが、小さなことは積み重なる。

速度#

Vitestは速い。意味のある速さだ。「2秒節約」レベルの速さではなく、「ワークフローが変わる」レベルの速さだ。Viteのモジュールグラフを使ってファイル変更の影響を受けるテストを特定し、それだけを実行する。ウォッチモードはViteの開発サーバーを瞬時に感じさせるのと同じHMRインフラを使う。

400以上のテストがあるプロジェクトで、JestからVitestに切り替えたところ、ウォッチモードのフィードバックループが約4秒から500ms未満に短縮された。「テストが通るまで待とう」と「キーボードに指を置いたままターミナルをちらっと見る」の違いだ。

ベンチマーク機能の内蔵#

Vitestにはパフォーマンステスト用のbench()が標準で含まれている。別途ライブラリは不要だ:

typescript
import { bench, describe } from "vitest";
 
describe("string concatenation", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("string concat", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

vitest benchで実行する。メインイベントではないが、benchmark.jsをインストールして別のランナーを接続せずとも、同じツールチェーン内でパフォーマンステストができるのは便利だ。

セットアップ#

インストール#

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

設定#

プロジェクトルートにvitest.config.tsを作成する:

typescript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    css: false,
  },
});

すでにvite.config.tsがある場合は、それを拡張できる:

typescript
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": "/src",
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

globals: true の選択#

globalstrueの場合、describeitexpectbeforeEachなどをインポートする必要がない — Jestと同じように、どこでも利用可能だ。falseの場合は明示的にインポートする:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("math", () => {
  it("adds numbers", () => {
    expect(1 + 1).toBe(2);
  });
});

私はglobals: trueを使っている。視覚的なノイズが減り、ほとんどの開発者が期待する動作と一致するからだ。明示的インポートを重視するチームなら、falseに設定すればいい — ここに正解・不正解はない。

globals: trueを使う場合、TypeScriptが認識できるようにtsconfig.jsonにVitestの型を追加する:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

環境:jsdom vs happy-dom vs node#

Vitestではテストごと、またはグローバルにDOM実装を選択できる:

  • node — DOMなし。純粋なロジック、ユーティリティ、APIルート、ブラウザに触れないものすべて向け。
  • jsdom — 標準。完全なDOM実装。重いがより完全。
  • happy-dom — jsdomより軽量で高速だが、完全性は劣る。一部のエッジケース(RangeSelectionIntersectionObserverなど)が動作しない可能性がある。

私はデフォルトでグローバルにjsdomを使い、nodeが必要なときだけファイル単位でオーバーライドする:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

セットアップファイル#

セットアップファイルはすべてのテストファイルの前に実行される。ここでTesting Libraryのマッチャーやグローバルモックを設定する:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// IntersectionObserverのモック — jsdomは実装していない
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// window.matchMediaのモック — レスポンシブコンポーネントに必要
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

@testing-library/jest-dom/vitestのインポートにより、toBeInTheDocument()toHaveClass()toBeVisible()などのマッチャーが使えるようになる。これらはDOM要素に対するアサーションを読みやすく表現力豊かにする。

良いテストの書き方#

AAAパターン#

すべてのテストは同じ構造に従う:Arrange(準備)、Act(実行)、Assert(検証)。明示的なコメントを書かなくても、この構造は見える形であるべきだ:

typescript
it("calculates total with tax", () => {
  // Arrange
  const items = [
    { name: "Widget", price: 10 },
    { name: "Gadget", price: 20 },
  ];
  const taxRate = 0.08;
 
  // Act
  const total = calculateTotal(items, taxRate);
 
  // Assert
  expect(total).toBe(32.4);
});

準備、実行、検証をメソッドチェーンの一連の呼び出しにまとめたテストを見ると、失敗したときに理解しにくくなるとわかる。コメントを追加しなくても、3つのフェーズを視覚的に区別できるようにしよう。

テストの命名#

二つの流派がある:it('should calculate total with tax')it('calculates total with tax')。「should」接頭辞は情報を追加せず冗長だ。テストが失敗すると、こう表示される:

FAIL  ✕ calculates total with tax

これですでに完全な文だ。「should」を追加してもノイズが増えるだけだ。私は直接的な表現を好む:it('renders loading state')it('rejects invalid email')it('returns empty array when no matches found')

describeブロックにはテスト対象のユニット名を使う:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

声に出して読んでみよう:「calculateTotalはアイテム価格を合計する」「calculateTotalは税率を適用する」。文として成り立つなら、命名も正しい。

テストごとにアサーション1つ vs 実用的なグルーピング#

純粋主義のルールでは、テストごとにアサーション1つ。実用的なルールでは、テストごとに1つの概念。これらは異なる。

typescript
// これは問題ない — 1つの概念、複数のアサーション
it("formats user display name", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// これは問題あり — 1つのテストに複数の概念
it("handles user operations", () => {
  const user = createUser("John");
  expect(user.id).toBeDefined();
 
  const updated = updateUser(user.id, { name: "Jane" });
  expect(updated.name).toBe("Jane");
 
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

最初のテストは3つのアサーションがあるが、すべて1つのこと — 表示名のフォーマット — を検証している。いずれかのアサーションが失敗すれば、何が壊れているかが正確にわかる。2番目のテストは3つの別々のテストを詰め込んだものだ。2番目のアサーションが失敗しても、作成と更新のどちらが壊れているかわからず、3番目のアサーションは実行されない。

ドキュメントとしてのテスト記述#

良いテストスイートはリビングドキュメントとして機能する。コードに馴染みのない人でも、テストの記述を読めば機能の振る舞いが理解できるはずだ:

typescript
describe("PasswordValidator", () => {
  describe("minimum length", () => {
    it("rejects passwords shorter than 8 characters", () => { /* ... */ });
    it("accepts passwords with exactly 8 characters", () => { /* ... */ });
  });
 
  describe("character requirements", () => {
    it("requires at least one uppercase letter", () => { /* ... */ });
    it("requires at least one number", () => { /* ... */ });
    it("requires at least one special character", () => { /* ... */ });
  });
 
  describe("common password check", () => {
    it("rejects passwords in the common passwords list", () => { /* ... */ });
    it("performs case-insensitive comparison", () => { /* ... */ });
  });
});

このテストスイートを実行すると、出力は仕様書のように読める。それが目標だ。

モック#

モックはテストツールキットの中で最も強力で、最も危険なツールだ。上手に使えば、テスト対象のユニットを分離し、テストを高速かつ決定的にする。下手に使えば、コードが何をしようとも合格するテストができあがる。

vi.fn() — モック関数の作成#

最もシンプルなモックは、呼び出しを記録する関数だ:

typescript
const mockCallback = vi.fn();
 
// 呼び出す
mockCallback("hello", 42);
mockCallback("world");
 
// 呼び出しを検証する
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

戻り値を設定できる:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

連続する呼び出しで異なる値を返すこともできる:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — 実メソッドの監視#

動作を置き換えずにメソッドを観察したいとき:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOnはデフォルトで元の実装を保持する。必要に応じて.mockImplementation()でオーバーライドできるが、その後は.mockRestore()で元に戻すこと。

vi.mock() — モジュールレベルのモック#

これが大物だ。vi.mock()はモジュール全体を置き換える:

typescript
// モジュール全体をモック
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// インポートはモックされたバージョンを使用する
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitestはvi.mock()の呼び出しを自動的にファイルの先頭にホイストする。つまり、インポートが実行される前にモックが配置される。インポート順序を気にする必要はない。

自動モック#

すべてのエクスポートをvi.fn()に置き換えたいだけなら:

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

ファクトリ関数なしの場合、Vitestはすべてのエクスポートを自動モックする。エクスポートされた各関数はundefinedを返すvi.fn()になる。これは、個々の関数を指定せずにモジュールをサイレンスしたい場合(アナリティクスやロギングなど)に便利だ。

クリア vs リセット vs リストア#

これは誰もが一度はつまずくポイントだ:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — 呼び出し履歴をリセット、実装は維持
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // まだ42を返す
 
// mockReset — 呼び出し履歴と実装をリセット
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // もう42を返さない
 
// mockRestore — スパイ用、元の実装に戻す
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random()が再び正常に動作する

実際には、テスト間の呼び出し履歴をリセットするためにbeforeEachvi.clearAllMocks()を使う。spyOnを使っていて元に戻したい場合はvi.restoreAllMocks()を使う:

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

過剰モックの危険性#

モックに関して私が伝えられる最も重要なアドバイスはこれだ:すべてのモックはテストに対する嘘である。 依存関係をモックするとき、「このものが正しく動作すると信じるので、簡略化したバージョンに置き換える」と言っているのだ。仮定が間違っていれば、テストは合格するが機能は壊れている。

typescript
// 過剰モック — 何も有用なことをテストしていない
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("processes user input", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

このテストはprocessInputvalidateformatを呼び出すことを検証する。しかし、processInputがそれらを間違った順序で呼び出したら?戻り値を無視していたら?バリデーションがフォーマットステップの実行を防ぐはずだったとしたら?テストにはわからない。興味深い動作をすべてモックで消してしまっている。

経験則:境界でモックし、中間でモックしない。 ネットワークリクエスト、ファイルシステムアクセス、サードパーティサービスをモックする。やむを得ない理由がない限り(実行コストが高い、副作用があるなど)、自分のユーティリティ関数をモックしてはいけない。

Reactコンポーネントのテスト#

Testing Libraryの基本#

Testing Libraryは哲学を強制する:ユーザーが操作するようにコンポーネントをテストする。内部状態のチェックなし、コンポーネントインスタンスの検査なし、シャローレンダリングなし。コンポーネントをレンダリングし、ユーザーと同じようにDOMを通じて操作する。

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renders with label text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
  });
 
  it("applies variant classes", () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

クエリ:getBy vs queryBy vs findBy#

ここは初心者が混乱するポイントだ。3つのクエリバリアントがあり、それぞれに特定のユースケースがある:

getBy* — 要素を返すか、見つからない場合はスローする。要素が存在することを期待するときに使う:

typescript
// ボタンが見つからなければスロー — テストはわかりやすいエラーで失敗
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — 要素を返すか、見つからない場合はnullを返す。何かが存在しないことをアサートするときに使う:

typescript
// nullを返す — スローしない
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — Promiseを返す。非同期的に表示される要素に使う:

typescript
// 要素が表示されるまで最大1000ms待つ
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

アクセシビリティ優先のクエリ#

Testing Libraryはこれらのクエリを意図的な優先順位で提供している:

  1. getByRole — 最良のクエリ。ARIAロールを使用する。コンポーネントがロールで見つからないなら、アクセシビリティの問題があるかもしれない。
  2. getByLabelText — フォーム要素用。入力にラベルがないなら、まずそれを修正しよう。
  3. getByPlaceholderText — 許容できるが弱い。プレースホルダーはユーザーが入力すると消える。
  4. getByText — 非インタラクティブ要素用。表示テキストの内容で検索する。
  5. getByTestId — 最終手段。セマンティックなクエリがどれも機能しない場合に使う。
typescript
// これを優先
screen.getByRole("textbox", { name: "Email address" });
 
// これより
screen.getByPlaceholderText("Enter your email");
 
// そして間違いなくこれより
screen.getByTestId("email-input");

この順位は恣意的なものではない。支援技術がページをナビゲートする方法と一致している。ロールとアクセシブルな名前で要素を見つけられるなら、スクリーンリーダーもそうできる。テストIDでしか見つけられないなら、アクセシビリティにギャップがあるかもしれない。

ユーザーイベント#

fireEventを使わないこと。@testing-library/user-eventを使うこと。この違いは重要だ:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filters results as user types", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchInput onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "vitest");
 
    // user.typeは各文字に対してkeydown、keypress、input、keyupを発火する
    // fireEvent.changeは値を設定するだけ — リアルなイベントフローをスキップする
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("clears input on escape key", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hello");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEventは実際のブラウザが発火するイベントチェーン全体をシミュレートする。fireEvent.changeは単一の合成イベントだ。コンポーネントがonKeyDownをリッスンしていたり、onChangeの代わりにonInputを使っていたりする場合、fireEvent.changeはそれらのハンドラーをトリガーしないが、userEvent.typeはトリガーする。

必ず最初にuserEvent.setup()を呼び出し、返されたuserインスタンスを使うこと。これにより、適切なイベント順序と状態追跡が保証される。

コンポーネントインタラクションのテスト#

現実的なコンポーネントテストはこのようになる:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("adds a new todo item", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /new todo/i });
    const addButton = screen.getByRole("button", { name: /add/i });
 
    await user.type(input, "Write tests");
    await user.click(addButton);
 
    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("marks a todo as completed", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("removes completed items when clear button is clicked", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Done task", done: true },
          { id: "2", text: "Pending task", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /clear completed/i }));
 
    expect(screen.queryByText("Done task")).not.toBeInTheDocument();
    expect(screen.getByText("Pending task")).toBeInTheDocument();
  });
});

注目してほしい:内部状態の検査なし、component.setState()なし、実装の詳細のチェックなし。レンダリングし、操作し、ユーザーが見るものをアサートする。コンポーネントが内部の状態管理をuseStateからuseReducerにリファクタリングしても、これらのテストは通り続ける。それがポイントだ。

非同期コードのテスト#

waitFor#

コンポーネントが非同期に更新されるとき、waitForはアサーションが通るまでポーリングする:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  // 最初はローディングを表示
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  // コンテンツが表示されるのを待つ
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // ローディングインジケーターは消えているはず
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

waitForはデフォルトで50msごとにコールバックを再試行し、通るかタイムアウト(デフォルト1000ms)まで続ける。両方カスタマイズ可能だ:

typescript
await waitFor(
  () => expect(screen.getByText("Done")).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

フェイクタイマー#

setTimeoutsetIntervalDateを使うコードをテストするとき、フェイクタイマーで時間を制御できる:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("delays execution by the specified time", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
 
  it("resets timer on subsequent calls", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    vi.advanceTimersByTime(200);
 
    debounced(); // タイマーをリセット
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

重要:必ずafterEachvi.useRealTimers()を呼び出すこと。テスト間でリークしたフェイクタイマーは、これまでデバッグする中で最も混乱する失敗を引き起こす。

フェイクタイマーと非同期レンダリングの組み合わせ#

フェイクタイマーとReactコンポーネントテストの組み合わせには注意が必要だ。Reactの内部スケジューリングはリアルタイマーを使うため、タイマーの進行とReactの更新フラッシュを一緒に行う必要がよくある:

typescript
import { render, screen, act } from "@testing-library/react";
 
describe("Notification", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("auto-dismisses after 5 seconds", async () => {
    render(<Notification message="Saved!" autoDismiss={5000} />);
 
    expect(screen.getByText("Saved!")).toBeInTheDocument();
 
    // act()内でタイマーを進めてReactの更新をフラッシュする
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
  });
});

MSWによるAPIモック#

データフェッチングのテストには、Mock Service Worker(MSW)がネットワークレベルでネットワークリクエストをインターセプトする。つまり、コンポーネントのfetch/axiosコードはプロダクションとまったく同じように実行される — MSWはネットワークレスポンスだけを置き換える:

typescript
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
 
const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]);
  }),
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: "Alice",
      email: "alice@example.com",
    });
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("UserList", () => {
  it("displays users from API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("shows error state when API fails", async () => {
    // このテストだけデフォルトハンドラーをオーバーライド
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

MSWがfetchaxiosを直接モックするより優れている理由:

  1. コンポーネントの実際のデータフェッチングコードが実行される — 本物の統合をテストできる。
  2. テストごとにハンドラーをオーバーライドしてエラーハンドリングをテストできる。
  3. 同じハンドラーがテストとブラウザ開発モード(例えばStorybook)の両方で動く。

統合テスト#

ユニットテスト vs 統合テスト#

ユニットテストは単一の関数やコンポーネントを分離し、それ以外をすべてモックする。統合テストは複数のユニットを協調させ、外部境界(ネットワーク、ファイルシステム、データベース)だけをモックする。

実のところ、私がプロダクションで見てきたバグのほとんどは、ユニットの内部ではなく、ユニット間の境界で発生する。関数は単体では完璧に動くが、呼び出し側がわずかに異なるフォーマットでデータを渡すため失敗する。コンポーネントはモックデータでは問題なくレンダリングされるが、実際のAPIレスポンスに余分なネストレベルがあると壊れる。

統合テストはこれらのバグを捕捉する。ユニットテストより遅く、失敗時のデバッグも難しいが、テスト1つあたりの信頼性はより高い。

複数コンポーネントの同時テスト#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// APIレイヤーだけモック — それ以外はすべて本物
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Shopping Cart Flow", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("displays item count and total", () => {
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
      { id: "2", name: "Mouse", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 items")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("updates quantity and recalculates total", async () => {
    const user = userEvent.setup();
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
    await user.click(incrementButton);
 
    expect(screen.getByText("$159.98")).toBeInTheDocument();
  });
 
  it("completes checkout flow", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /checkout/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
    expect(screen.getByText("ORD-123")).toBeInTheDocument();
  });
});

このテストでは、ShoppingCartCartProviderとその内部コンポーネント(アイテム行、数量セレクター、合計表示)がすべて実際のコードで協調する。唯一のモックはAPI呼び出しだ。テストで実際のネットワークリクエストを行いたくないからだ。

統合テスト vs ユニットテストの使い分け#

ユニットテストを使う場面:

  • ロジックが複雑でエッジケースが多い場合(日付パーサー、ステートマシン、計算)。
  • 特定の関数の動作に対する速いフィードバックが必要な場合。
  • ユニットが比較的独立していて、他のユニットに大きく依存していない場合。

統合テストを使う場面:

  • 複数のコンポーネントが正しく連携する必要がある場合(バリデーションと送信のあるフォーム)。
  • データが複数のレイヤーを流れる場合(コンテキスト → コンポーネント → 子コンポーネント)。
  • 関数の戻り値ではなく、ユーザーワークフローをテストする場合。

実際には、健全なテストスイートは機能に対する統合テストが多く、複雑なユーティリティにはユニットテストがある。コンポーネント自体は統合テストを通じてテストされる — 統合テストがそのコンポーネントを実行しているなら、個々の小さなコンポーネントに対して別々のユニットテストは必要ない。

カバレッジ#

カバレッジの実行#

bash
vitest run --coverage

カバレッジプロバイダーが必要だ。Vitestは2つサポートしている:

bash
# V8 — より高速、V8の組み込みカバレッジを使用
npm install -D @vitest/coverage-v8
 
# Istanbul — より成熟、設定オプションが多い
npm install -D @vitest/coverage-istanbul

Vitest設定で構成する:

typescript
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/test/**",
        "src/**/*.d.ts",
        "src/**/types.ts",
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Istanbul vs V8#

V8カバレッジはV8エンジンの組み込みインストルメンテーションを使う。コード変換ステップがないため高速だ。ただし、特にESモジュール境界周辺の一部のエッジケースでは精度が低いことがある。

Istanbulはテスト実行前にソースコードにカウンターを挿入する。低速だが、より実績があり、ブランチカバレッジの精度が高い。CIでカバレッジの閾値を強制しているなら、Istanbulの精度が重要になるかもしれない。

私はローカル開発にはV8(より速いフィードバック)、CIにはIstanbul(より正確な強制)を使っている。必要に応じて環境ごとに異なるプロバイダーを設定できる。

カバレッジが実際に意味すること#

カバレッジはテスト中にどの行のコードが実行されたかを示す。それだけだ。それらの行が正しくテストされたかどうかは教えてくれない。これを考えてみよう:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // アサーションなし!
});

このテストはdivide関数の100%カバレッジを与える。しかし、何もテストしていない。dividenullを返しても、エラーをスローしても、ミサイルを発射しても、このテストは通る。

カバレッジは有用なネガティブ指標だ:低いカバレッジはテストされていないパスが確実に存在することを意味する。しかし高いカバレッジは、コードが十分にテストされていることを意味しない。すべての行が何らかのテスト中に実行されたということにすぎない。

行 vs ブランチ#

行カバレッジは最も一般的な指標だが、ブランチカバレッジのほうがより価値がある:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

getDiscount({ isPremium: true, yearsActive: 10 })のテストはすべての行を通る(100%行カバレッジ)が、3つのブランチのうち2つしかテストしていない。isPremium: falseのパスとyearsActive <= 5のパスはテストされていない。

ブランチカバレッジはこれを検出する。条件分岐ロジックを通るすべての可能なパスを追跡する。カバレッジの閾値を強制するなら、ブランチカバレッジを使おう。

生成コードの除外#

一部のコードはカバレッジに含めるべきではない。生成ファイル、型定義、設定 — これらは価値を追加せずに指標を膨らませる:

typescript
// vitest.config.ts
coverage: {
  exclude: [
    "src/**/*.d.ts",
    "src/**/types.ts",
    "src/**/*.stories.tsx",
    "src/generated/**",
    ".velite/**",
  ],
}

ソースコード内の特定の行やブロックを除外することもできる:

typescript
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
  console.log("Debug info:", data);
}
/* v8 ignore stop */
 
// Istanbulの場合
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }

これは控えめに使うこと。大きなコードの塊を除外していることに気づいたら、それらのコードにはテストが必要か、そもそもカバレッジレポートに含めるべきではないかのどちらかだ。

Next.jsのテスト#

next/navigationのモック#

useRouterusePathnameuseSearchParamsを使うNext.jsコンポーネントにはモックが必要だ:

typescript
import { vi } from "vitest";
 
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => "/en/blog",
  useSearchParams: () => new URLSearchParams("?page=1"),
  useParams: () => ({ locale: "en" }),
}));

ナビゲーションが呼ばれたことを検証する必要があるテストの場合:

typescript
import { useRouter } from "next/navigation";
 
vi.mock("next/navigation", () => ({
  useRouter: vi.fn(),
}));
 
describe("LogoutButton", () => {
  it("redirects to home after logout", async () => {
    const mockPush = vi.fn();
    vi.mocked(useRouter).mockReturnValue({
      push: mockPush,
      replace: vi.fn(),
      back: vi.fn(),
      prefetch: vi.fn(),
      refresh: vi.fn(),
      forward: vi.fn(),
    });
 
    const user = userEvent.setup();
    render(<LogoutButton />);
 
    await user.click(screen.getByRole("button", { name: /log out/i }));
 
    expect(mockPush).toHaveBeenCalledWith("/");
  });
});

next-intlのモック#

next-intlを使う国際化コンポーネントの場合:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

これは最もシンプルなアプローチだ — 翻訳はキー自体を返すので、t("hero.title")"hero.title"を返す。アサーションでは、実際の翻訳された文字列ではなく翻訳キーをチェックする。これによりテストが言語に依存しなくなる。

特定のテストで実際の翻訳が必要な場合:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => {
    const translations: Record<string, string> = {
      "hero.title": "Welcome to My Site",
      "hero.subtitle": "Building things for the web",
    };
    return (key: string) => translations[key] ?? key;
  },
}));

ルートハンドラーのテスト#

Next.jsのルートハンドラーはRequestを受け取ってResponseを返す通常の関数だ。テストは簡単だ:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returns users list", async () => {
    const request = new NextRequest("http://localhost:3000/api/users");
    const response = await GET(request);
    const data = await response.json();
 
    expect(response.status).toBe(200);
    expect(data).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
      ])
    );
  });
 
  it("supports pagination via search params", async () => {
    const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
    const response = await GET(request);
    const data = await response.json();
 
    expect(data.page).toBe(2);
    expect(data.items).toHaveLength(10);
  });
});
 
describe("POST /api/users", () => {
  it("creates a new user", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
    });
 
    const response = await POST(request);
    const data = await response.json();
 
    expect(response.status).toBe(201);
    expect(data.name).toBe("Alice");
  });
 
  it("returns 400 for invalid body", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "" }),
    });
 
    const response = await POST(request);
    expect(response.status).toBe(400);
  });
});

ミドルウェアのテスト#

Next.jsのミドルウェアはエッジで実行され、すべてのリクエストを処理する。関数としてテストする:

typescript
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
 
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
  const url = new URL(path, "http://localhost:3000");
  return new NextRequest(url, { headers });
}
 
describe("middleware", () => {
  it("redirects unauthenticated users from protected routes", async () => {
    const request = createRequest("/dashboard");
    const response = await middleware(request);
 
    expect(response.status).toBe(307);
    expect(response.headers.get("location")).toContain("/login");
  });
 
  it("allows authenticated users through", async () => {
    const request = createRequest("/dashboard", {
      cookie: "session=valid-token",
    });
    const response = await middleware(request);
 
    expect(response.status).toBe(200);
  });
 
  it("adds security headers", async () => {
    const request = createRequest("/");
    const response = await middleware(request);
 
    expect(response.headers.get("x-frame-options")).toBe("DENY");
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  });
 
  it("handles locale detection", async () => {
    const request = createRequest("/", {
      "accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
    });
    const response = await middleware(request);
 
    expect(response.headers.get("location")).toContain("/tr");
  });
});

サーバーアクションのテスト#

サーバーアクションはサーバー上で実行される非同期関数だ。単なる関数なので直接テストできる — ただし、サーバー専用の依存関係をモックする必要があるかもしれない:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
    },
  },
}));
 
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));
 
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
 
describe("updateProfile", () => {
  it("updates user and revalidates profile page", async () => {
    const formData = new FormData();
    formData.set("name", "New Name");
    formData.set("bio", "New bio text");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
 
  it("returns error for invalid data", async () => {
    const formData = new FormData();
    // 必須フィールドが欠落
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

実践的パターン#

カスタムレンダー関数#

ほとんどのプロジェクトでは、すべてのコンポーネントに同じプロバイダーをラップする必要がある。カスタムレンダーを作成しよう:

typescript
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
 
interface CustomRenderOptions extends RenderOptions {
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}
 
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
  children: React.ReactNode;
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}) {
  return (
    <ThemeProvider defaultTheme={theme}>
      <CartProvider initialItems={initialCartItems}>
        {children}
      </CartProvider>
    </ThemeProvider>
  );
}
 
export function renderWithProviders(
  ui: ReactElement,
  options: CustomRenderOptions = {}
) {
  const { theme, initialCartItems, ...renderOptions } = options;
 
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders theme={theme} initialCartItems={initialCartItems}>
        {children}
      </AllProviders>
    ),
    ...renderOptions,
  });
}
 
// testing libraryからすべてを再エクスポート
export * from "@testing-library/react";
export { renderWithProviders as render };

これですべてのテストファイルが@testing-library/reactの代わりにカスタムユーティリティからインポートする:

typescript
import { render, screen } from "@/test/utils";
 
it("renders in dark mode", () => {
  render(<Header />, { theme: "dark" });
  // HeaderとそのすべてのchildrenがThemeProviderとCartProviderにアクセスできる
});

カスタムフックのテスト#

Vitestは@testing-library/reactrenderHookと連携する:

typescript
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
 
describe("useCounter", () => {
  it("starts at initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  it("increments", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  it("decrements with floor", () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(0); // min以下にはならない
  });
});

エラーバウンダリのテスト#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Test explosion");
};
 
describe("ErrorBoundary", () => {
  // 予期されるエラーのconsole.errorを抑制
  beforeEach(() => {
    vi.spyOn(console, "error").mockImplementation(() => {});
  });
 
  afterEach(() => {
    vi.restoreAllMocks();
  });
 
  it("displays fallback UI when child throws", () => {
    render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowingComponent />
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Something went wrong")).toBeInTheDocument();
  });
 
  it("renders children when no error", () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <div>All good</div>
      </ErrorBoundary>
    );
 
    expect(screen.getByText("All good")).toBeInTheDocument();
    expect(screen.queryByText("Error")).not.toBeInTheDocument();
  });
});

スナップショットテスト(慎重に)#

スナップショットテストは評判が悪い。人々が本物のアサーションの代わりとして使うからだ。コンポーネントのHTML出力全体のスナップショットは保守の負担になる — CSSクラスが変わるたびに壊れ、差分を丁寧にレビューする人はいない。

しかし、対象を絞ったスナップショットは有用だ:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// 良い — 純粋関数の出力の小さく対象を絞ったスナップショット
it("formats various currency values consistently", () => {
  expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
  expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
  expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
  expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
 
// 悪い — 誰もレビューしない巨大なスナップショット
it("renders the dashboard", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // これはやめよう
});

インラインスナップショット(toMatchInlineSnapshot)はファイルスナップショットより良い。期待値がテスト内で直接見えるからだ。別の.snapファイルを開かずに関数が何を返すか一目でわかる。

テスト哲学#

振る舞いをテストし、実装をテストしない#

この原則は非常に重要なので、専用のセクションに値する。同じ機能に対する2つのテストを考えてみよう:

typescript
// 実装テスト — 脆弱、リファクタリングで壊れる
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// 振る舞いテスト — 弾力的、ユーザーが見るものをテスト
it("increments the displayed count when plus button is clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

最初のテストはuseStateからuseReducerに切り替えると壊れる。コンポーネントがまったく同じように動作していてもだ。2番目のテストはコンポーネントの振る舞いが実際に変わったときだけ壊れる。カウントが内部でどう管理されているかは気にしない — 「+」をクリックすると数字が上がることだけを確認する。

リトマステストはシンプルだ:テストを変えずに実装をリファクタリングできるか? できるなら、振る舞いをテストしている。できないなら、実装をテストしている。

テスティングトロフィー#

Kent C. Doddsは従来のテストピラミッドの代替として「テスティングトロフィー」を提案した:

    ╭─────────╮
    │  E2E    │   少数 — 高コスト、低速、高い信頼性
    ├─────────┤
    │         │
    │ 統合    │   最多 — 信頼性対コスト比が良い
    │         │
    ├─────────┤
    │ ユニット │   いくつか — 高速、集中、低コスト
    ├─────────┤
    │ 静的    │   常に — TypeScript、ESLint
    ╰─────────╯

従来のピラミッドは底辺にユニットテスト(大量)、中間に統合テスト(少なめ)を置く。トロフィーはこれを逆転させる:統合テストがスイートスポットだ。その理由:

  • 静的解析(TypeScript、ESLint)はタイプミス、型の間違い、単純な論理エラーをタダで検出する。何も実行する必要すらない。
  • ユニットテストは複雑な純粋ロジックには最適だが、部品が連携して動くかどうかは教えてくれない。
  • 統合テストはコンポーネント、フック、コンテキストが連携して動くことを検証する。書いたテスト1つあたり最も高い信頼性を与える。
  • エンドツーエンドテストはシステム全体を検証するが、低速で不安定で保守コストが高い。クリティカルパスにはいくつか必要だが、数百は不要だ。

私は実践でこの配分に従っている:TypeScriptが型エラーのほとんどを検出し、複雑なユーティリティやアルゴリズムにはユニットテストを書き、機能やユーザーフローには統合テストを書き、クリティカルパス(サインアップ、購入、コアワークフロー)にはE2Eテストを少し書く。

信頼性を与えるものと時間を無駄にするもの#

テストは出荷する自信を与えるために存在する。すべてのコード行が実行される自信ではなく、アプリケーションがユーザーにとって動くという自信だ。これらは別物だ。

高い信頼性、高い価値:

  • チェックアウトフローの統合テスト — フォームバリデーション、API呼び出し、状態更新、成功/エラーUIをカバーする。
  • エッジケース付きの価格計算関数のユニットテスト — 浮動小数点、丸め、割引、ゼロ/負の値。
  • 保護されたルートが未認証ユーザーをリダイレクトするテスト。

低い信頼性、時間の無駄:

  • 静的マーケティングページのスナップショットテスト — コピーが変わるたびに壊れ、意味のあるものを何も捕捉しない。
  • コンポーネントが子にpropsを渡すユニットテスト — 自分のコードではなくReact自体をテストしている。
  • useStateが呼ばれるテスト — フレームワークをテストしているのであり、振る舞いではない。
  • 設定ファイルの100%カバレッジ — 静的データであり、TypeScriptがすでにその形状を検証している。

テストを書く前に問うべき質問:「このテストが存在しなかったら、どんなバグがプロダクションに忍び込めるか?」 答えが「TypeScriptが検出しないものはない」または「誰も気づかないもの」なら、そのテストはおそらく書く価値がない。

設計フィードバックとしてのテスト#

テストしにくいコードは、通常は設計が悪いコードだ。1つの関数をテストするために5つのものをモックする必要があるなら、その関数は依存関係が多すぎる。手の込んだコンテキストプロバイダーを設定しないとコンポーネントをレンダリングできないなら、そのコンポーネントは環境に過度に結合している。

テストはコードのユーザーだ。テストがAPIを使うのに苦労しているなら、他の開発者もそうだろう。テストのセットアップと格闘していることに気づいたら、それをモックを追加するシグナルではなく、テスト対象のコードをリファクタリングするシグナルと捉えよう。

typescript
// テストしにくい — 関数がやりすぎている
async function processOrder(orderId: string) {
  const order = await db.orders.findById(orderId);
  const user = await db.users.findById(order.userId);
  const inventory = await checkInventory(order.items);
  if (!inventory.available) {
    await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
    return { success: false, reason: "out-of-stock" };
  }
  const payment = await chargeCard(user.paymentMethod, order.total);
  if (!payment.success) {
    await sendEmail(user.email, "payment-failed", { error: payment.error });
    return { success: false, reason: "payment-failed" };
  }
  await db.orders.update(orderId, { status: "confirmed" });
  await sendEmail(user.email, "order-confirmed", { orderId });
  return { success: true };
}
 
// テストしやすい — 関心事が分離されている
function determineOrderAction(
  inventory: InventoryResult,
  payment: PaymentResult
): OrderAction {
  if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
  if (!payment.success) return { type: "payment-failed", error: payment.error };
  return { type: "confirmed" };
}

2番目のバージョンは純粋関数だ。データベース、決済プロバイダー、メールサービスをモックせずに、在庫と支払い結果のすべての組み合わせをテストできる。オーケストレーションロジック(データの取得、メールの送信)は、統合レベルでテストされる薄いレイヤーに存在する。

これがテストの真の価値だ:バグが書かれた後に検出することではなく、悪い設計がコミットされる前に防ぐことだ。テストを書く規律が、より小さな関数、より明確なインターフェース、よりモジュラーなアーキテクチャへと導く。テストは副産物だ。設計の改善がメインイベントだ。

信頼できるテストを書く#

テストスイートに起こり得る最悪のことは、ギャップがあることではない。人々がそれを信頼しなくなることだ。CIでランダムに失敗するフレーキーテストがいくつかあるテストスイートは、チームにレッドビルドを無視することを教える。一度そうなると、テストスイートは無用以上に悪い — 偽りの安心感を積極的に提供してしまう。

テストが断続的に失敗するなら、修正するか削除しよう。テストが遅いなら、高速化するか別のスローテストスイートに移動しよう。テストが無関係な変更のたびに壊れるなら、実装ではなく振る舞いをテストするように書き直そう。

目標は、すべての失敗が何かが本当に壊れていることを意味するテストスイートだ。開発者がテストを信頼すれば、コミットのたびにテストを実行する。信頼しなければ、--no-verifyでバイパスし、祈りながらデプロイする。

週末を賭けてもいいテストスイートを構築しよう。それ以下のものは保守する価値がない。

関連記事