1.テストの基本構造
Vitest では describe と it を組み合わせてテストを構造化する。
describe("テスト対象", () => {
it("期待する振る舞い", () => {
// テスト内容
});
});
✅ describeの粒度 = 実装の粒度
describe の切り方には明確な基準がある。
| 階層 | 対応するもの |
|---|---|
外側 describe
|
クラス / モジュール |
内側 describe
|
メソッド |
it |
そのメソッドの1シナリオ |
describe("受注Repository", () => { // モジュール単位
describe("Search", () => { // メソッド単位
it("一覧と総件数が返ること", ...);
it("マッチしない場合は空配列が返ること", ...);
});
describe("SearchById", () => { // メソッド単位
it("IDに一致する受注が返ること", ...);
it("存在しないIDの場合はnullが返ること", ...);
});
});
テストコードの構造 = 実装の構造 にすることで、以下のメリットがある。
- どのメソッドのテストが不足しているか一目でわかる
- テスト失敗時に「どのメソッドで何が壊れたか」がレポートから即わかる
- メソッドを追加・削除したとき、対応する
describeごと足す/消せる
なお、1つのメソッドでも振る舞いが大きく異なる分岐(create / edit など)は、内側 describe でさらに分けるのが自然。
describe("Save", () => {
describe("create", () => { ... });
describe("edit", () => { ... });
});
💡 参考(C#使いの方へ)
C# の xUnit では [Fact] や [Theory] をクラスに並べる形が一般的だが、
Vitest はネストした describe でクラス相当の階層を表現する。
| xUnit | Vitest |
|---|---|
| テストクラス | 外側 describe
|
| テストメソッド | it |
ネストしたクラス(public class 内部クラス) |
内側 describe
|
2.セットアップ
✅ vitest.config.ts の設定ポイント
test: {
environment: "jsdom", // ブラウザAPIをシミュレート(Reactコンポーネントのレンダリングに必要)
globals: true, // describe/it/expect を import なしで使える
clearMocks: true, // テスト間でモックの呼び出し履歴を自動リセット
setupFiles: ["tests/setup.ts"], // 各テスト前に実行するセットアップファイル
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
coverage: {
provider: "v8",
exclude: [
"src/components/ui/**", // shadcn/ui のコード(テスト対象外)
"src/db/schema.ts", // DBスキーマ定義(ロジックなし)
],
thresholds: { // 閾値を下回るとテストが失敗する
statements: 90,
branches: 85,
functions: 75,
lines: 90,
},
},
},
clearMocks について
clearMocks: true を設定すると、各テスト後にモックの 呼び出し履歴(calls) が自動でリセットされる。
これがないと、前のテストでモックが何回呼ばれたかが残り、後続テストの toHaveBeenCalledTimes などがズレる。
coverage.exclude について
shadcn/ui のようなサードパーティ由来のコードや、Zodスキーマ・DBスキーマのような「ロジックのない定義ファイル」は除外する。
これらをカバレッジ対象のままにすると、全体の数値を不当に下げてしまう。
✅ tests/setup.ts の役割
setupFiles に指定したファイルは、各テストファイルの実行前に自動で読み込まれる。
このプロジェクトでは2つのことをしている。
import "@testing-library/jest-dom";
// ↑ toBeInTheDocument() などのDOM専用マッチャーを有効にする
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {};
};
// ↑ jsdom に存在しない Web API のポリフィル
// cmdk(コマンドパレットUI)など一部ライブラリが内部で使用する
jsdom はブラウザの完全な実装ではないため、一部の Web API が存在しない。
エラーが出たらポリフィルを追加する、という対応になる。
3.テスト範囲の分離
テストツールごとに「何を検証するか」の責務が異なる。
| テスト種別 | ツール | 検証すること |
|---|---|---|
| 単体テスト | Vitest | 1モジュールの責務(DBや外部APIはモックに差し替える) |
| E2Eテスト | Playwright | 実ブラウザ・実DBを繋いだ画面操作の動作確認 |
✅ Vitestのテスト範囲
「依存をモックに差し替えて、そのモジュール単体の責務だけを検証する」 のが Vitest の役割。
Repositoryのテストを例にすると:
- ⭕ Vitest が検証すること: ORMが正しいクエリを組み立てたか
- ❌ Vitest が検証しないこと:そのSQLをDBが正しく実行するか(DBの責務)
// DBには繋がず、チェーンの構造と引数+ダミーの戻り値だけ確認する
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([row]),
}),
}),
});
const result = await 商品Repository.SearchById("abc");
expect(result).toEqual({ 商品CD: "abc", 商品名: "テスト商品" });
✅ E2Eテストの範囲
「実ブラウザ・実DBを繋いで、ユーザー操作の結果が正しいか」 を検証するのが E2E の役割。
- ⭕ E2E が検証すること:画面操作 → APIコール → DB書き込み → 画面反映の一連の流れ
- ❌ E2E が検証しないこと:個別モジュールの細かい分岐(組み合わせ爆発するため Vitest に任せる)
✅ なぜ分けるのか
| Vitest | E2E | |
|---|---|---|
| 速度 | 速い(DB不要) | 遅い(ブラウザ・DB起動が必要) |
| 粒度 | 細かい分岐まで網羅しやすい | 代表的なシナリオに絞る |
| 失敗箇所 | 即特定できる | どこで壊れたか追うのが大変 |
細かい分岐は Vitest で速く網羅し、E2E は「繋いだら本当に動くか」の最終確認に使う、という役割分担が現場では一般的。
4.Vitestの実装サンプル
ここからは具体例的なテスト対象とテストコードが対で無いとわかりにくいと思います。
Vitestのテストコードとテスト対象(受注デモアプリ)をすべて公開しています。
✅ フォルダ構成
src/ # アプリケーション
tests/ # テスト用コード
├── setup.ts # 各テスト前に実行されるセットアップ
├── __mocks__/ # モジュール全体のモック定義
├── unit/ # Vitest(単体テスト)
│ ├── actions/ # Server Actions のテスト
│ ├── components/ # UI コンポーネントのテスト
│ ├── hooks/ # カスタムHook のテスト
│ ├── lib/ # ユーティリティ・認証ガード等のテスト
│ ├── models/ # Zod モデルのバリデーションテスト
│ ├── pages/ # page.tsx のテスト
│ └── repository/ # Repository のテスト
└── e2e/ # Playwright(E2Eテスト)
unit/ 以下はテスト対象の種別ごとにフォルダを分けている。src/ 側のフォルダ構造を模倣するのではなく、テストの責務単位 で整理している点がポイント。
5.Modelのテスト
Zodで定義したModelのバリデーションをテストする。
✅ テスト対象のコード
export const 商品Model = z.object({
商品CD: z.string().min(1, "必須"),
商品名: z.string().min(1, "必須"),
単価: nonNegativeNumericSchema("単価は必須です"),
備考: z.string().optional().nullable(),
version: z.number().default(0),
});
✅ テストコード(テストデータと正常系テスト)
// テスト用データ
const validProduct: 商品Output = {
商品CD: "PROD-001",
商品名: "テスト商品",
単価: 1000,
version: 0,
};
// 正常系のテスト
describe("商品Model", () => {
it("正常な商品データが通ること", () => {
const result = 商品Model.safeParse(validProduct);
expect(result.success).toBe(true);
});
});
Zod.safeParseの実行結果
result.success(true/false)を確認
result.data: パースの成功時のみ存在する(型安全)
result.error: パース失敗時のみ存在(エラー詳細)
✅ テストコード(テストデータから特定のプロパティを削除してテスト)
it("versionのデフォルト値が0であること", () => {
// テストデータvalidProductから、versionプロパティを削除
const { version: _, ...rest } = validProduct;
const result = 商品Model.safeParse(rest);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.version).toBe(0);
});
const { version: \_, ...rest } = validProduct;
validProductのversionを除外した残りをrestに格納する
💡 参考(C#使いの方へ)
| xUnit | Vitest |
|---|---|
| Assert.Equal(y, x) ※参照比較 | expect(x).toBe(y) |
| Assert.Equal(y, x) ※値比較(オブジェクト向き) | expect(x).toEqual(y) |
| Assert.True(x) | expect(x).toBe(true) |
| Assert.False(x) | expect(x).toBe(false) |
| Assert.Null(x) | expect(x).toBeNull() |
| Assert.NotNull(x) | expect(x).toBeDefined() |
| Assert.Null(x)(undefinedはC#に無い概念) | expect(x).toBeUndefined() |
| Assert.Equal(n, x.Count) | expect(x).toHaveLength(n) |
| Assert.Contains(y, x) | expect(x).toContain(y) |
| Assert.Throws(() => fn()) | expect(fn).toThrow("msg") |
| Assert.True(x > n) | expect(x).toBeGreaterThan(n) |
expect(result.success).toBe(true);
は、プリミティブ型の true と比較しているので、本来であれば値比較の
toEqual(true)の方が意味あいとして正確だと思います。
ただし Vitest では慣習としてプリミティブ型の比較は toBe が使われる。
✅ テストコード(異常系テスト)
it("商品CDが空の場合はエラーになること", () => {
const result = 商品Model.safeParse({ ...validProduct, 商品CD: "" });
// 商品CDを""で上書きすると safeParseに失敗すること
expect(result.success).toBe(false);
if (result.success) return;
// 商品CDを含むエラーが1件以上存在すること
const issue = result.error!.issues.filter((i) => i.path.includes("商品CD"));
expect(issue.length).toBeGreaterThanOrEqual(1);
});
{ ...validProduct, 商品CD: "" }
テストデータvalidProductを展開して、商品CDだけ "" で上書きしています
result.error!.issues.filterで発生したエラー(複数エラーかも)の中から
includes("商品CD")で 商品CD関連のエラーがあるか確認しています
💡 参考(C#使いの方へ)
| LINQ | JS 配列メソッド |
|---|---|
| .Any(x => ...) | .some(x => ...) |
| .All(x => ...) | .every(x => ...) |
| .Where(x => ...) | .filter(x => ...) |
| .Select(x => ...) | .map(x => ...) |
| .First(x => ...) | .find(x => ...) |
| .FirstOrDefault() | .find() (なければ undefined) |
| .Count() | .length |
| .Aggregate() | .reduce() |
6.Repositoryのテスト
✅ 対象コード
Drizzle ORM を使ったシンプルな Repository を例にする。
import { eq } from "drizzle-orm";
import { db } from "@/db/drizzle";
import { 商品 } from "@/db/schema";
export const 商品Repository = {
async SearchById(商品CD: string) {
const results = await db
.select()
.from(商品)
.where(eq(商品.商品CD, 商品CD))
.limit(1);
return results[0] ?? null;
},
};
✅ 何をテストするか
Repository のテストは「DBから何を取得できるか」ではなく
「正しいクエリを組み立てたか」 を検証する。
- DB の絞り込みが正しく動くか → DB 側の責務
-
SearchByIdがwhereに正しい条件を渡したか → Repository の責務
そのため、テストでは実際に DB へは繋がず、モックに差し替える。
✅ モックの考え方
実コードのメソッドチェーンとテストのメソッドチェーンを一致させる。
実コードは .select().from().where().limit() というチェーンで動く。
const results = await db
.select()
.from(商品)
.where(eq(商品.商品CD, 商品CD))
.limit(1);
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([dbRow]),
}),
}),
});
モックに差し替えるとき、このチェーンの形は再現しなければならない。
チェーンの形状が正しいことをテストしなければ意味がない。
やることは3つ:
- チェーンの形を再現する (チェーン構造の検証)
- モック自体は引数で振る舞いを変えない (常に固定値を返す)
- 最終的な戻り値だけ固定値にする
✅ モックの読み方
ネストが深いがやっていることは非常にシンプル
- ネストは実コードの構成と同じにする
- ネストの最深部で(上記の例ではlimit)、selectの結果を返す
✅ mockReturnValue vs mockResolvedValue
| メソッド | 使いどころ |
|---|---|
mockReturnValue |
チェーンの途中(次のメソッドを持つオブジェクトを返す) |
mockResolvedValue |
チェーンの末尾(await で受け取る Promise を返す) |
実コードで await しているのは最後のチェーンだけなので、
末尾だけ mockResolvedValue、途中は mockReturnValue になる。
✅ 引数の確認方法
// 元コード
await db.update(テーブル).set({
名前: データ.名前,
version: 現在のversion + 1,
});
// テストコード
const mockSet = vi.fn().mockResolvedValue({ rowCount: 1 });
mockDb.update.mockReturnValueOnce({ set: mockSet });
await Repository.Update("更新後の名前", 2);
expect(mockSet).toHaveBeenCalledWith({
名前: "更新後の名前",
version: 3, // 現在のversion(2) + 1
});
.set() に渡した引数が意図通りか検証。
✅ 引数の部分一致の確認
// 元コード(updatedAt が含まれる)
await db.update(テーブル).set({
名前: データ.名前,
version: 現在のversion + 1,
updatedAt: new Date(), // 毎回変わる
});
// テストコード
// version に 3 を渡していればOK
expect(mockSet).toHaveBeenCalledWith(
expect.objectContaining({
version: 3,
}),
);
expect.objectContaining で「指定したプロパティが含まれていればOK」にする。
✅ 実行回数
expect(mockReturning).toHaveBeenCalledOnce();
引数ではなく「1回だけ呼ばれたか」を検証。引数なしメソッドに使う。
// 回数を指定する書き方
expect(mockReturning).toHaveBeenCalledTimes(1);
✅ Repository内の例外スローを確認
await expect(
得意先Repository.Update("customer-uuid-001", 0, { ... }),
).rejects.toThrow("対象のデータは別のユーザーによって...");
Promiseが例外を投げること、かつメッセージが一致することを検証。
7.機能のテスト
ここでは「商品マスタメンテナンス」を例に、各テストの考え方やテクニックを順番に見ていきます。
✅ テストの責務分離
「商品マスタメンテナンス」機能は src/app/(protected)/master/product/ 以下の複数ファイルで構成されている。
src/app/(protected)/master/product/
├── page.tsx
├── actions.ts
└── _components/
├── 商品Dialog.tsx
├── 商品List.tsx
└── 商品ListServer.tsx
各ファイルを独立してテストし、それぞれのテストが担う責務は以下の通り。
| ファイル | テストの責務 |
|---|---|
| page.tsx | 認可チェック・タイトル表示・子コンポーネントの呼び出し確認 |
| actions.ts | 認可チェック・Repository 呼び出し・revalidatePath・エラーハンドリング |
| 商品ListServer.tsx | Repository を正しい引数で呼ぶこと・子コンポーネントの表示確認 |
| 商品List.tsx | データ表示・検索・ページング・UI 要素 |
| 商品Dialog.tsx | フォーム表示・バリデーション・アクション呼び出し |
各テストでは 子コンポーネントをモックに差し替える。
子コンポーネントの内部動作はそれぞれのテストに委譲し、「呼び出されること・引数が正しいこと」だけを確認する。
vi.mock(
"@/app/(protected)/master/product/_components/商品ListServer",
() => ({
ProductListServer: () => (
<div data-testid="mock-product-list">Product List</div>
),
}),
);
「呼び出されているか」だけ確認すれば十分。
it("ProductListServer コンポーネントが表示されること", async () => {
const searchParams = Promise.resolve({});
const pageElement = await ProductPage({ searchParams });
render(pageElement);
expect(screen.getByTestId("mock-product-list")).toBeInTheDocument();
});
✅ URLクエリパラメータの受け取り確認
Next.js 15 から searchParams の型が Promise<{...}> に変わった。テストでは Promise.resolve() で「解決済みのPromise」を作って渡す。
// クエリパラメータなし
const searchParams = Promise.resolve({});
// クエリパラメータあり
const searchParams = Promise.resolve({ q: "ガンダム", page: "3" });
Promise.resolve(値) は「最初から解決済みの Promise を作る」構文。
実際の非同期処理を走らせる必要がないテストでは、結果だけ直接渡すために使う。
✅ レンダリング結果を確認する
(1) タグから確認する(getByRole)
getByRole("heading") は h1〜h6 すべてにマッチする。
level / name / selector で絞り込める。
screen.getByRole("heading"); // h1〜h6 すべて
screen.getByRole("heading", { level: 1 }); // h1 のみ
screen.getByRole("heading", { level: 1, name: "商品マスタメンテナンス" }); // テキストも一致
getByRole は 1つだけヒットする前提 のメソッド。複数ヒットするとエラーになる。
複数取得したい場合は getAllByRole を使う。上記のコードは「この画面に h1 でテキストが "商品マスタメンテナンス" の要素は1つのはず」という仕様の宣言でもある。
// selector でタグ名を直接指定する書き方(level: 1 と同義)
screen.getByRole("heading", { selector: "h1" });
// selector が役立つ例:同じ role を持つ異なるタグを区別する
// input と textarea はどちらも role="textbox" になる
screen.getByRole("textbox", { selector: "textarea" }); // textarea のみ
screen.getByRole("textbox", { selector: "input" }); // input のみ
(2) data-testid から確認する(getByTestId)
// <div data-testid="mock-product-list">Product List</div>
expect(screen.getByTestId("mock-product-list")).toBeInTheDocument();
id 属性による直接取得(getById)は Testing Library には存在しない。
テスト専用の識別子には data-testid を使う。
(3) テキストから確認する(getByText)
expect(screen.getByText("ガンダム")).toBeInTheDocument();
表示テキストそのものでマッチする。リスト項目やセルの内容確認によく使う。
getByRole の name: オプションとの違いに注意。name: はアクセシブルネームを参照するので aria-label など非表示の属性も対象になる。getByText は DOM に 表示されているテキスト だけが対象。
// <button aria-label="閉じる">×</button>
screen.getByText("閉じる"); // ❌ 表示テキストは "×"
screen.getByText("×"); // ⭕ 表示テキストで見つかる
screen.getByRole("button", { name: "閉じる" }); // ⭕ aria-label で見つかる
✅ フォームの初期値を確認する(getByDisplayValue)
expect(screen.getByDisplayValue("テスト商品")).toBeInTheDocument();
input / textarea に現在セットされている値でマッチする。編集ダイアログのように既存データをフォームに反映する実装の確認によく使う。
✅ 要素が存在しないことを確認する(queryBy)
expect(screen.queryByRole("button", { name: "削除" })).not.toBeInTheDocument();
getByXxx は要素が見つからないとエラーになる。「存在しないこと」を確認したい場合は queryByXxx を使う。見つからないときは null を返すので not.toBeInTheDocument() と組み合わせて検証できる。
✅ 要素の活性・非活性を確認する
expect(cdInput).not.toBeDisabled(); // 活性(編集可能)
expect(cdInput).toBeDisabled(); // 非活性(読み取り専用)
新規登録時は編集可・編集時は変更不可、のように mode によって状態が変わるフィールドの確認に使う。
✅ 属性値を確認する(toHaveAttribute)
expect(screen.getByTestId("mock-order-form")).toHaveAttribute(
"data-mode",
"create",
);
data-* 属性や type 属性など、DOM 属性の値を直接確認する。
モックコンポーネントに data-mode を持たせて「どのモードで呼ばれたか」を検証するパターンはよく使う。
✅ フォームの現在値を確認する(toHaveValue)
expect(screen.getByRole("textbox", { selector: "input" })).toHaveValue("");
input や select に現在セットされている値を確認する。
getByDisplayValue は「その値で要素を取得する」のに対して、toHaveValue は「取得済みの要素の値を検証する」という使い分けになる。
✅ フォーム操作と router.push を検証する
検索フォームのような「入力 → 送信 → URL遷移」のフローは、fireEvent と mockPush を組み合わせてテストします。
it("キーワードを入力して検索すると q と page=1 で router.push が呼ばれること", () => {
render(
<ProductList pageData={sampleProducts} totalCount={2} pageSize={20} />,
);
fireEvent.change(screen.getByPlaceholderText("検索ワードを入力…"), {
target: { value: "ガンダム" },
});
fireEvent.submit(
screen.getByRole("button", { name: "検索" }).closest("form")!,
);
expect(mockPush).toHaveBeenCalledWith(
"?q=%E3%82%AC%E3%83%B3%E3%83%80%E3%83%A0&page=1",
);
});
fireEvent.change
input 要素の値を直接セットします。ユーザーがキーボードで入力したのと同じ状態を作ります。
fireEvent.submit
form 要素に submit イベントを発火します。ボタンをクリックするのではなく、フォーム自体を submit するので、.closest("form") で親フォームを取得してから渡します。ボタンに直接 fireEvent.submit しても発火しないので注意。
mockPush の検証
router.push はモックなので toHaveBeenCalledWith で「何の引数で呼ばれたか」を確認できます。URLクエリは自動でエンコードされるため、「ガンダム」は %E3%82%AC%E3%83%B3%E3%83%80%E3%83%A0 になります。
💡 引数の簡略化テクニック
モックの戻り値を設定するとき、型の完全な一致を要求されて手間が掛かることがあります。
// 型エラー:戻り値の型に必要なプロパティが揃っていない
vi.mocked(商品Repository.Insert).mockResolvedValueOnce([
{ 商品CD: "PROD-001" },
]);
mockResolvedValueOnce の引数は実際の戻り値の型と一致しなければならないため、全プロパティを揃えないと型エラーになります。
戻り値の中身を検証しないテスト(「呼ばれたか」だけを見るテストなど)では、as never でこの型チェックをスルーできます。
vi.mocked(商品Repository.Insert).mockResolvedValueOnce([
{ 商品CD: "PROD-001" },
] as never);
never は TypeScript のすべての型のサブタイプなので、どんな型にも代入可能です。これにより mockResolvedValueOnce の 引数の型チェックを強制的に通過 させられます。
テストコードでは慣習的に利用されますが、本番コードでは利用しません。
as neverを使う理由
例えば関数内で 認証チェック の呼び出しが行われるか?
というテストでは、引数を丁寧にセットする意味が無いので as never で簡略化します。
8.カスタムHookのテスト
カスタムHookは renderHook を使ってテストする。Hookはコンポーネントの中でしか使えないが、renderHook がその環境を用意してくれる。
✅ renderHook の基本
import { renderHook } from "@testing-library/react";
import { useDebounce } from "@/hooks/use-debounce";
it("初期値が即座に返されること", () => {
const { result } = renderHook(() => useDebounce("初期値", 300));
expect(result.current).toBe("初期値");
});
result.current に Hook の戻り値が入る。
✅ フェイクタイマー
setTimeout や setInterval に依存する Hook は、テストで実際に待つわけにいかない。vi.useFakeTimers() でタイマーを乗っ取り、vi.advanceTimersByTime() で時間を手動で進める。
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers(); // 他のテストに影響しないよう必ず元に戻す
});
it("delay経過後に値が更新されること", () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: "初期" } },
);
rerender({ value: "更新後" });
expect(result.current).toBe("初期"); // delay前はまだ古い値
act(() => {
vi.advanceTimersByTime(300); // 300ms進める
});
expect(result.current).toBe("更新後");
});
rerender に新しい props を渡すと Hook が再実行される。タイマーの操作は act() で囲む。
✅ 現在時刻の固定(vi.setSystemTime)
カスタムHookに限らず、new Date() に依存するロジック全般に使える。vi.setSystemTime() で時刻をピン留めする。
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-15T10:00:00+09:00")); // 任意の日時に固定
});
afterEach(() => {
vi.useRealTimers();
});
it("month: 当月1日〜末日になること", () => {
const result = getAnalysisDefaults("month");
expect(result.duration.from).toBe("2026-04-01");
expect(result.duration.to).toBe("2026-04-30");
});
vi.useFakeTimers() を呼ばないと vi.setSystemTime() が機能しないので注意。
9.認証・認可のテスト
✅ redirect() のモック
Next.js の redirect() は内部で 例外を throw する特殊な関数。
モックで普通に vi.fn() に差し替えただけでは、実際の redirect が持つ「処理を中断する」という挙動が再現できない。
vi.mock("next/navigation", () => ({
redirect: vi.fn().mockImplementation((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`);
}),
}));
モック側でも throw させることで本番と同じ制御フローを再現する。テスト側は rejects.toThrow で受け取る。
it("未認証の場合 redirect('/login') が呼ばれること", async () => {
vi.mocked(auth.api.getSession).mockResolvedValueOnce(null);
await expect(requireSession()).rejects.toThrow("NEXT_REDIRECT:/login");
expect(redirect).toHaveBeenCalledWith("/login");
});
✅ vi.mock + await import() の組み合わせ
vi.mock() はファイルの先頭で宣言するが、「呼ばれたか」を確認するには モックの参照 が必要になる。
vi.mock("@/lib/auth-guard", () => ({
requireAdmin: vi.fn().mockResolvedValue({ user: { role: "admin" } }),
}));
it("requireAdmin が呼ばれること", async () => {
// ↓ テスト内で動的 import してモックの参照を取得する
const { requireAdmin } = await import("@/lib/auth-guard");
const { 商品Repository } = await import("@/db/repository/商品Repository");
vi.mocked(商品Repository.Insert).mockResolvedValueOnce([
{ 商品CD: "PROD-001" },
] as never);
await save商品(validProductData, false);
expect(requireAdmin).toHaveBeenCalledOnce();
});
vi.mock() でモジュールを差し替えた後、テスト内で await import() するとモック済みの関数が取得できる。clearMocks: true(または beforeEach で vi.clearAllMocks())と組み合わせてテスト間の汚染を防ぐ。
10.コンポーネントテストで使う主なマッチャー一覧
DOM専用マッチャー(@testing-library/jest-dom 提供)とモック検証マッチャーをまとめる。
| 検証内容 | マッチャー |
|---|---|
| 要素が DOM に存在する | .toBeInTheDocument() |
| 要素が DOM に存在しない | .not.toBeInTheDocument() |
要素が disabled 状態 |
.toBeDisabled() |
要素が disabled でない(編集可能) |
.not.toBeDisabled() |
| 属性名と値が一致する | .toHaveAttribute("name", "val") |
input / select の value が "text" であること |
.toHaveValue("text") |
| 配列・NodeList の要素数が n | .toHaveLength(n) |
| モックが1回以上呼ばれた | .toHaveBeenCalled() |
| モックが一度も呼ばれていない | .not.toHaveBeenCalled() |
| モックがちょうど1回呼ばれた | .toHaveBeenCalledOnce() |
| 特定の引数で呼ばれた | .toHaveBeenCalledWith(...) |
| 最後の呼び出しの引数が一致する | .toHaveBeenLastCalledWith(...) |
//利用例
expect(screen.getByTestId("mock-order-form")).toHaveAttribute(
"data-mode",
"edit",
);
参考:Vitest実行結果
