4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React/Next.jsプロジェクトの自動テスト(Vitest)実装ガイド

4
Posted at

1.テストの基本構造

Vitest では describeit を組み合わせてテストを構造化する。

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 の設定ポイント

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つのことをしている。

tests/setup.ts
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のバリデーションをテストする。

✅ テスト対象のコード

src/db/model/商品Model.ts
export const 商品Model = z.object({
  商品CD: z.string().min(1, "必須"),
  商品名: z.string().min(1, "必須"),
  単価: nonNegativeNumericSchema("単価は必須です"),
  備考: z.string().optional().nullable(),
  version: z.number().default(0),
});

✅ テストコード(テストデータと正常系テスト)

tests/unit/models/商品Model.test.ts
// テスト用データ
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: パース失敗時のみ存在(エラー詳細)

✅ テストコード(テストデータから特定のプロパティを削除してテスト)

tests/unit/models/商品Model.test.ts
  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;
validProductversion を除外した残りを 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 が使われる。

✅ テストコード(異常系テスト)

tests/unit/models/商品Model.test.ts
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 を例にする。

src/db/repository/商品Repository.ts
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 側の責務
  • SearchByIdwhere に正しい条件を渡したか → 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つ:

  1. チェーンの形を再現する (チェーン構造の検証)
  2. モック自体は引数で振る舞いを変えない (常に固定値を返す)
  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")h1h6 すべてにマッチする。
level / name / selector で絞り込める。

screen.getByRole("heading"); // h1〜h6 すべて
screen.getByRole("heading", { level: 1 }); // h1 のみ
screen.getByRole("heading", { level: 1, name: "商品マスタメンテナンス" }); // テキストも一致

getByRole1つだけヒットする前提 のメソッド。複数ヒットするとエラーになる。
複数取得したい場合は 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();

表示テキストそのものでマッチする。リスト項目やセルの内容確認によく使う。

getByRolename: オプションとの違いに注意。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("");

inputselect に現在セットされている値を確認する。
getByDisplayValue は「その値で要素を取得する」のに対して、toHaveValue は「取得済みの要素の値を検証する」という使い分けになる。

✅ フォーム操作と router.push を検証する

検索フォームのような「入力 → 送信 → URL遷移」のフローは、fireEventmockPush を組み合わせてテストします。

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 の戻り値が入る。

✅ フェイクタイマー

setTimeoutsetInterval に依存する 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(または beforeEachvi.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実行結果

image.png

4
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?