フロントエンドテスト
ユーザーが見て操作するものをテストし、コンポーネントがどのように構築されているかはテストしません。ループのためには naki-tdd、完全なブラウザフローのためには naki-e2e-playwright、セマンティクスのためには naki-accessibility と組み合わせて使用します。プロジェクトにプロジェクトルールスキルがある場合(例:naki-myapp)は、それに従ってください。
使用するタイミング
- 新しいコンポーネント、フック、またはページにテストが必要な場合。
- 動作を変更しないリファクタリング後にテストが壊れた場合(実装結合の兆候)。
- テストが不安定、
waitForをあちこちで使用、またはアプリの半分をモックしている場合。
コアルール
- ユーザーのようにクエリする。 優先順位:
getByRole(name付き)、getByLabelText、getByPlaceholderText、getByText、getByDisplayValue。アクセシブルな代替手段がない場合のみgetByTestIdを使用します。 fireEventではなくuserEventで操作する。const user = userEvent.setup()で作成し、すべての呼び出しをawaitします。- 出力にアサーションを行う: 可視テキスト、ロール、フォーム値、ネットワークエンドポイントへの呼び出し。状態変数、フックの内部、CSS クラス名、コンポーネントのインスタンスメソッドにはアサーションを行わないでください。
- モジュールではなくネットワークをモックする。 HTTP レイヤーで MSW ハンドラーを使用します。モジュールをモックするのは、真の外部境界(時間、乱数、jsdom にないブラウザ API)に対してのみにしてください。
- 後で表示されるものには
findBy*を使用する。waitForはクエリではないアサーションのために予約し、waitFor内に副作用を決して入れないでください。 - テストごとに1つの振る舞い。文のように名前を付けます:
shows an error when the server returns 500。 - 各テストは自身の状態をセットアップする。 順序依存なし。
afterEachでハンドラー、タイマー、ストレージをリセットします。 - 本物のプロバイダーを
renderWithProvidersヘルパーで提供する(ルーター、リトライ無効のクエリクライアント、テーマ)。モックしないでください。 - アクセシビリティはテスト可能。
getByRole("button", { name: "Save" })の失敗は、しばしば実際のアクセシビリティバグを示します。必要に応じて、主要な画面にvitest-axeチェックを追加しても構いません。 - カバレッジは目標ではなくシグナルです。 重要なブランチをカバーする:エラー状態、空状態、権限。
クエリ早見表
| 要素 | クエリ |
|---|---|
| ボタン | getByRole("button", { name: /save/i }) |
| リンク | getByRole("link", { name: "Docs" }) |
| ラベル付きテキスト入力 | getByRole("textbox", { name: "Email" }) または getByLabelText("Email") |
| チェックボックス | getByRole("checkbox", { name: "Remember me" }) |
| 見出し | getByRole("heading", { level: 2, name: "Alerts" }) |
| テーブル行 | getAllByRole("row") 次に within(row) |
| ダイアログ | getByRole("dialog", { name: "Delete alert" }) |
| 非存在 | expect(queryByText("Error")).not.toBeInTheDocument() |
ワークフロー
- 振る舞いをリストアップ:レンダリング状態(ローディング、空、データ、エラー)とインタラクション。
- コンポーネントが呼び出すエンドポイントの MSW ハンドラーを追加。デフォルトは成功。
- 振る舞いごとに1つのテストを書き、エラーケースでは
server.use(...)でハンドラーをオーバーライド。 npx vitest run path/to/file.test.tsxを実行し、次にスイート全体を実行。- 意図的に実装を一度壊す(一行コメントアウト)ことで、テストが失敗することを確認。
セットアップ
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./src/test/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); cleanup(); });
afterAll(() => server.close());
onUnhandledRequest: "error" により、未処理のリクエストがあった場合にハングせずに失敗します。
例
import { http, HttpResponse } from "msw";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { server } from "../test/server";
import { AlertList } from "./AlertList";
test("lists alerts and filters by severity", async () => {
server.use(http.get("/api/alerts", () => HttpResponse.json([
{ id: "a1", title: "Brute force on vpn", severity: "high" },
{ id: "a2", title: "New admin login", severity: "low" },
])));
const user = userEvent.setup();
render(<AlertList />);
expect(await screen.findByText("Brute force on vpn")).toBeInTheDocument();
await user.selectOptions(screen.getByRole("combobox", { name: "Severity" }), "high");
expect(screen.queryByText("New admin login")).not.toBeInTheDocument();
});
test("shows a retry button when loading fails", async () => {
server.use(http.get("/api/alerts", () => new HttpResponse(null, { status: 500 })));
render(<AlertList />);
expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument();
});
フックと時間
- フックは、それを使用する小さなコンポーネントを通じてテストするか、UI がない場合は
renderHookを使用してテストします。 - 偽のタイマー:
vi.useFakeTimers()とuserEvent.setup({ advanceTimers: vi.advanceTimersByTime })。afterEachでvi.useRealTimers()で復元します。 - 日付を固定するには
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))を使用します。これによりスナップショットとラベルが安定します。
チェックリスト
- クエリはロールまたはラベルベースである。
- すべての
userEvent呼び出しがawaitされている。 - ネットワークは MSW でモックされ、未処理のリクエストは失敗する。
- ローディング、エラー、空の状態がカバーされている。
- 内部やクラス名に対するアサーションがない。
- テストは単独でも任意の順序でも成功する。
- 出力に
act(...)警告がない。
よくある間違い
| 間違い | 修正 |
|---|---|
どこでも getByTestId を使用 | ロール/ラベルクエリを使用。コンポーネントにラベルがない場合は追加 |
await waitFor(() => getByText("x")) | await findByText("x") |
タイピングに fireEvent.change を使用 | await user.type(input, "text") |
useAlerts フックを直接モック | HTTP エンドポイントをモック |
| ページ全体の大きなスナップショット | 重要なものに明示的なアサーション |
| リトライ有効のクエリクライアント、遅いエラーテスト | テストプロバイダーで retry: false |
act 警告を無視 | findBy* で非同期 UI 更新を待つ |