Reactの統合テストでモックの乱用を防ぎ信頼性を高めるためのベストプラクティスと実践アプローチ

Reactの統合テストでモックを適切に制限し、UI振る舞いを中心に検証する信頼性向上のためのベストプラクティスをまとめた記事アイキャッチ フロントエンド

Reactの統合テストにおいて、モック(mock)は外部依存性を排除しテストの実行速度を保つ強力な道具です。
しかし、この道具を濫用すると、テストは実装の詳細に過度に結合し、リファクタリングで容易に壊れる脆弱なものへと変貌します。
信頼性の高い統合テストとは、ユーザーの操作フローに近い形でコンポーネント間の連携を検証することにあります。
モックはその補助手段に過ぎず、主役はあくまで実際のモジュール同士の相互作用です。

では、どこでモックを差し込むべきでしょうか。
判断基準は「制御不能な外部リソース」と「非決定性な振る舞い」に集約されます。

  • モックが適切なケース:サードパーティのAPIクライアント、ブラウザのStorage API、日時生成関数(new Date())など、テスト環境では再現が困難または不安定な要素
  • モックを避けるべきケース:自前のカスタムフック、状態管理ロジック(ZustandやReduxの純粋なReducer)、ユーティリティ関数(フォーマットやバリデーション)—これらは実装をそのまま使い、ネットワーク応答だけをモックすることで、実際のビジネスロジックを検証できます

実践的なアプローチとして、テストごとにモックを個別に設定するのではなく、セットアップファイルで共通のモックを定義し、テストケースでは必要なオーバーライドだけを行う戦略が有効です。
これにより、テスト間の干渉を防ぎつつ、モックの乱立を抑制できます。
また、モックの戻り値は具体的なハードコード値ではなく、ファクトリ関数を用いて型安全に生成することを推奨します。
例えば、mockFetchResponse のようなヘルパーを用意し、ステータスコードやペイロードをパラメータ化することで、エッジケースの再現性が格段に向上します。

さらに、統合テストでは「モックした外部サービスが期待通りに呼ばれたか」を検証するよりも、その結果としてUIに表示されるテキストやボタンの有効/無効状態、遷移先のURLなどをアサーションの対象にしてください。
これらはユーザーが知覚できる変化であり、実装の変更に強い耐性を持ちます。

テスト対象 モック推奨度 代替手段
REST API クライアント 高(MSW推奨) ローカルサーバー起動
ブラウザのローカルストレージ 中(スパイ) テスト用メモリ実装
日時・乱数生成関数 高(固定値) 依存性注入で置換
内部カスタムフック 低(実装使用) ラッパーコンポーネント経由

最後に、モックの数を定期的に見直す習慣を持ちましょう。
テストコード内のモック定義が増えすぎたら、それは設計のシグナルです。
モックを減らす方向にリファクタリングする—例えば、外部通信を集約するアダプタ層を導入し、そのアダプタのみをモックする。
この原則を守るだけで、テストは実装ではなく振る舞いを語る仕様書へと昇華します。
統合テストの真価は、モックをいかに賢く限定し、本番に近い信頼性を手に入れるかにかかっているのです。

  1. なぜReact統合テストはモックの乱用で腐敗するのか─その根本原因
    1. モックが生む「実装結合」という負のスパイラル
    2. モックの多さは設計のシグナルである
    3. モックがもたらす「偽りの自信」という危険性
    4. 根本原因は「恐怖」と「慣習」にある
  2. モックが適切な境界線─制御不能な外部依存と非決定性処理の見極め方
    1. モックが必須となる2つの条件
    2. 具体例で見る「モックするもの」「しないもの」
    3. 非決定性処理への代替戦略─依存性注入の活用
    4. 外部APIはモックではなく「MSW」でネットワーク層を差し替える
    5. 判断に迷ったら「ユーザー視点」で考える
  3. モックを減らすための設計戦略─アダプタパターンと依存性注入の導入
    1. アダプタパターンで外部通信を一元化する
    2. 依存性注入をReactらしく実装する方法
    3. アダプタ導入がもたらす副次的な効能
    4. 導入の際の注意点と段階的アプローチ
  4. 実装詳細ではなくユーザー行動をアサーションせよ─画面遷移とUI状態の検証
    1. なぜUI状態と画面遷移が最適な検証対象なのか
    2. 具体的なアサーション手法─Testing Libraryの哲学に従う
    3. 画面遷移の検証はURLとページタイトルを中心に
    4. エラーケースとローディング状態も同様に扱う
    5. 実装詳細アサーションを完全に排除する必要はない
  5. モックファクトリとセットアップ共通化でテスト保守性を劇的に向上させる
    1. モックファクトリで型安全なデフォルト応答を用意する
    2. セットアップ共通化でテストの前処理を集約する
    3. セットアップファイル単位でのモジュールモック戦略
    4. 共通化がもたらす副作用とその対策
    5. 導入の第一歩は「重複を見つけること」
  6. モックとスパイの使い分け─呼び出し検証よりも結果検証を優先する理由
    1. 呼び出し検証がテストを実装依存にするメカニズム
    2. 結果検証がもたらす抽象度の高いアサーション
    3. スパイが例外的に有効なケース
    4. モックとスパイの物理的な分離戦略
    5. 結果検証への移行がもたらす文化的変化
  7. 統合テストでモック数を定量的に監視する─過剰モック検出のためのメトリクス
    1. モック過剰を示す3つの主要メトリクス
    2. モック数の異常値を検出するしきい値設計
    3. メトリクス収集を自動化するツールと手法
    4. メトリクス悪化時の具体的な改善アクション
    5. メトリクス導入で得られる副次的な効果
  8. まとめ─モックは必要最小限に、テストは振る舞いを語る仕様書へ
    1. 再確認すべき3つの基本原理
    2. 今すぐ始められる段階的な改善ロードマップ
    3. チーム文化としてのテスト品質の育て方
    4. 最後に─モックは道具、テストは資産

なぜReact統合テストはモックの乱用で腐敗するのか─その根本原因

複雑に絡み合ったReactコンポーネントと外部APIモックが乱立するテストコードの断面図

Reactの統合テストを書くとき、多くの開発者が無意識のうちにモックを増やしてしまいます。
外部APIを呼び出すカスタムフック、ブラウザのローカルストレージ、さらには内部のユーティリティ関数に至るまで、とにかく「テストが不安定になるのが怖い」という理由でモックを差し込む。
この行動は短期的にはテストの実行速度を安定させますが、長期的にはテストスイート全体を脆弱で理解困難な産物に変えていきます
そのメカニズムを、コンピューターサイエンスの観点から分解して説明します。

モックが生む「実装結合」という負のスパイラル

モックは本質的に、テスト対象のコードが「どのように実装されているか」に依存します。
例えば、コンポーネントが内部でfetchUserという関数を呼んでいる場合、その関数をモックするテストは、その関数名や引数の構造が変わっただけで失敗します。
ここで問題なのは、ユーザーにとって無意味な変更─例えばリファクタリングで関数名をfetchUserProfileに変えただけ─でもテストが赤くなることです。
これが起こると、開発者はテストを「信頼できない障害物」とみなし、モックの更新作業に時間を割くか、最悪の場合テスト自体をスキップし始めます。

さらに悪いことに、モックの乱用はテストが実装のスナップショットになることを加速させます。
あるコンポーネントが3つのカスタムフックと2つのAPIクライアントを利用しているとします。
開発者はそれぞれを個別にモックし、それぞれの戻り値をスタブします。
するとテストコードは、実際のビジネスロジックではなく「モックがどう設定されているか」を検証するコードへと変質します。
結果として、テストが壊れたときに「実装が間違っているのか、モックの設定が間違っているのか」の区別がつかなくなり、デバッグコストが指数関数的に増大します。

モックの多さは設計のシグナルである

ソフトウェア工学の原則として、テストが書きにくいコードは設計が悪いコードという教訓があります。
統合テストで多くのモックが必要になる場合、それは多くの場合、以下の設計上の問題を内包しています。

  • コンポーネントが複数の外部依存性を直接参照している(単一責任の原則違反)
  • 副作用と純粋ロジックが混在している(関心の分離ができていない)
  • 外部リソースへのアクセスが抽象化されず、具体的な実装に直結している

これらの問題をモックで覆い隠しても、本質的な改善にはなりません。
むしろ、モックという「覆い」が増えるほど、設計の歪みは見えにくくなり、リファクタリングのチャンスを逃し続けることになります。
私は過去のプロジェクトで、テスト1件あたり平均8個のモックを定義しているコードベースを担当したことがありますが、機能追加のたびに既存テストの修正に半日を費やすという非効率な状態に陥りました。

モックがもたらす「偽りの自信」という危険性

最も見落とされがちなのは、モックが本番環境との乖離を生む点です。
モックは開発者が想定した応答パターンしか再現しません。
しかし実際のAPIはタイムアウト、異常なステータスコード、予期しないデータ構造など、無限に近いバリエーションを持ちます。
モックを多用したテストがすべてパスしても、本番でこれらのエッジケースに遭遇すれば、アプリケーションは確実に破綻します。

また、モックは非同期処理のタイミングも理想化します。
jest.useFakeTimersなどで時間を操作すると、実際のブラウザ環境では発生するレースコンディションやメモリリークがテストで検出されなくなります。
つまり、テストが通ることと、アプリケーションが正しく動作することは、モックが多いほど無関係になっていくのです。

根本原因は「恐怖」と「慣習」にある

技術的な要因だけでなく、心理的要因も無視できません。
CIパイプラインでたまたま失敗したテストを、APIの応答遅延やネットワーク不安定のせいにして、それをモックで固定化してしまう。
あるいは、既存のテストコードがすべてモックで書かれているから、新規テストもそれに倣う。
このような慣習的なモック信仰が、組織全体でテスト品質を劣化させます。

私が推奨するのは、モックを書く前に「この依存性は本当に制御不能か?」と自問することです。
制御可能な内部ロジック、つまり自分たちで実装した関数やカスタムフックは、モックせずに実装を通してテストします。
一方、サードパーティのSDKやブラウザAPIだけにモックを限定する。
この単純な線引きが、テストの信頼性を劇的に変える第一歩です。

次のセクションでは、この「制御不能な外部依存」と「制御可能な内部ロジック」を具体的にどのように見分け、どこにモックを投入すべきかを、実践的な基準とともに詳述していきます。

モックが適切な境界線─制御不能な外部依存と非決定性処理の見極め方

外部APIとブラウザストレージをモックし、内部ロジックは実装のままテストする境界線の図解

前章ではモックの乱用が招く弊害を論理的に分解しました。
では逆に、どのような要素に対してモックを投入することが正当化されるのか
この問いに答えるには、「制御可能性」と「決定性」という二つの軸で依存性を分類する必要があります。
統合テストにおいてモックは、決して「便利だから」使うものではなく、「そうせざるを得ない」場合に限定すべきです。
ここでは、その境界線を明確にするための実践的な判断基準を提示します。

モックが必須となる2つの条件

モックを検討する前に、まずその依存性が以下のいずれかに該当するかを評価します。

  • テスト環境で再現できない外部リソース:サードパーティの決済API、OAuth認証サーバー、CDN上のアセット、メール送信サービスなど。これらはローカルやCI環境では実際に呼び出せないか、呼び出してもコストやレート制限の問題が発生します
  • 非決定的な振る舞いを持つ処理:現在時刻を返すnew Date()、乱数生成、ネットワーク遅延、ファイルシステムの状態、ブラウザのリサイズイベントなど。これらの出力は実行のたびに変わるため、テストを再現可能にするには固定値で置き換える必要があります

この2条件に当てはまらない依存性、例えば自作のバリデーション関数や状態管理のReducer、データ変換のユーティリティは、決してモックしてはいけません
これらは制御可能で決定性が高いため、実装を通してテストすることで、リファクタリング耐性とバグ検出力が向上します。

具体例で見る「モックするもの」「しないもの」

実際のReactプロジェクトを想定して、典型的な依存性を分類してみましょう。

依存性の種類 具体例 モックの判断 理由
HTTPクライアント axios, fetch 推奨 ネットワーク越しの応答は環境依存かつ非決定的
ブラウザStorage localStorage, sessionStorage 推奨 テスト間で状態が共有され干渉するため
日時関数 Date.now(), Intl.DateTimeFormat 推奨 現在時刻に依存するとスナップショットが不安定化
内部カスタムフック useAuth, useCart 避ける アプリケーションロジックの中核であり、実装を検証すべき
状態管理のアクション Redux thunk, Zustand action 避ける ビジネスフローそのもの。モックすると試験価値が半減
ユーティリティ関数 文字列フォーマット、金額計算 避ける 純粋関数であり、入出力が一意に決まる

この表の基準を守るだけでも、テスト内のモック数はおそらく半分以下に減少します。
特に注目すべきは「内部カスタムフック」です。
多くの開発者はフック内部でAPIを呼んでいるからといってフックごとモックしますが、それは誤りです。
正しいアプローチはフック内部のAPIクライアントだけをモックし、フック自体は実装のまま動作させることです。
そうすれば、API応答に対するフックの状態変化(ローディング中、成功、エラー)を実際のロジックで検証できます。

非決定性処理への代替戦略─依存性注入の活用

日時や乱数のような非決定性処理は、モックではなく依存性注入(DI)で対処するのがよりエレガントです。
例えば、現在時刻を取得する関数を外部から受け取るように設計します。

// 悪い例:内部で直接 new Date() を呼ぶ
function getExpiration() {
  return new Date().getTime() + 3600000;
}

// 良い例:依存性として注入する
function getExpiration(now: () => number) {
  return now() + 3600000;
}

テスト時には() => 固定タイムスタンプを渡し、本番では() => Date.now()を渡す。
これにより、モックライブラリに依存せず、型安全かつ明示的に非決定性を排除できます。
同様に、乱数が必要な箇所にはシード付きの擬似乱数ジェネレータを注入することで、再現可能なテストが実現します。

外部APIはモックではなく「MSW」でネットワーク層を差し替える

従来はjest.mock('axios')のようにモジュール単位でモックすることが一般的でしたが、最近ではMSW(Mock Service Worker)を用いてネットワークリクエストそのものをインターセプトする手法が推奨されます。
MSWはブラウザとNode.jsの両方で動作し、実際のfetchやaxiosの実装を一切変更せずに、指定したエンドポイントに対して擬似応答を返せます。
これにより、モックは「関数の置き換え」から「ネットワーク応答のスタブ」へと抽象度が上がり、テストコードが実装詳細からさらに切り離されます。

MSWの導入初期コストはありますが、一度セットアップすれば全テストで共通の応答定義を再利用でき、モックの重複記述が劇的に減ります。
さらに、エラーステータス(500, 404)や遅延応答も容易にシミュレートできるため、エッジケースの網羅性も向上します。

判断に迷ったら「ユーザー視点」で考える

最後に、最もシンプルかつ強力な判断基準を紹介します。
その依存性をモックした場合、テストがユーザーにとって意味のある振る舞いを検証しているかを自問してください。
モックした結果、画面上のテキストやボタンの有効/無効、URLの変更など、ユーザーが知覚できる変化をアサーションしているなら、そのモックは適切です。
逆に、モックの呼び出し回数や引数の内容だけを検証しているなら、それは実装の詳細に過ぎず、モックを外すか、またはテスト自体を再設計すべきシグナルです。

次の章では、この判断基準を踏まえた上で、実際にモックを減らすための設計パターン─アダプタ層の導入と依存性注入の実装手法─について、具体的なコード例を交えながら解説します。

モックを減らすための設計戦略─アダプタパターンと依存性注入の導入

アダプタ層で外部通信を集約し、テスト時にその層だけを差し替える依存性注入の模式図

前章までで、モックを投入すべき境界線と、むしろモックを避けるべき領域を明確にしました。
しかし、現実のコードベースでは、外部APIやブラウザAPIがコンポーネントやカスタムフックのあちこちに直接記述されているケースが大半です。
この状態では、モックを減らそうとしても、テスト環境でそれらの依存性をどう扱うかという課題が立ちはだかります。
そこで本格的な解決策として登場するのが、アダプタパターン依存性注入(DI)という、ソフトウェア工学における古典的かつ強力な設計原則です。
これらをReactの文脈でどう適用するか、具体的に解説します。

アダプタパターンで外部通信を一元化する

アダプタパターンとは、外部システムとの通信インターフェースを抽象化し、アプリケーションのコアロジックから具体的な実装を切り離す設計手法です。
Reactプロジェクトでは、以下のように実装します。

  • 外部APIクライアントを直接コンポーネントで使わず、ApiAdapterという層を一つ挟む
  • ApiAdapterfetchaxiosを内部で使い、アプリケーションが必要とするデータ構造(例:UserProduct)を返す
  • コンポーネントやカスタムフックはこのApiAdapterインターフェースにのみ依存する

この構造を取ることで、テスト時にはApiAdapterのモックだけを作成すればよくなります。
従来のようにjest.mock('axios')を5つのテストファイルで繰り返す必要がなくなり、モックの定義箇所が一箇所に集約されます。
さらに、実際のAPI仕様が変わった場合でも、修正はApiAdapter内部のみで完結し、コンポーネント側は一切変更不要です。

コード例を示します。

// アダプタのインターフェース定義
interface IApiAdapter {
  fetchUser(id: string): Promise<User>;
  saveOrder(order: Order): Promise<OrderResult>;
}

// 本番用実装(axiosを使用)
class AxiosApiAdapter implements IApiAdapter {
  async fetchUser(id: string) {
    const res = await axios.get(`/users/${id}`);
    return res.data;
  }
  // saveOrderも同様
}

// テスト用のスタブ実装
class StubApiAdapter implements IApiAdapter {
  async fetchUser(id: string) {
    return { id, name: 'Test User', email: 'test@example.com' };
  }
  async saveOrder(order: Order) {
    return { success: true, orderId: 'mock-123' };
  }
}

このように、アダプタをインターフェースとして定義し、本番用とテスト用で異なる実装を注入するわけです。
Reactでは、このアダプタインスタンスをコンテキスト(Context)カスタムフックの引数として下位コンポーネントに渡すことで、グローバルなモックライブラリに頼らないクリーンなテストが実現します。

依存性注入をReactらしく実装する方法

従来のDIコンテナは大規模なバックエンドで使われるイメージがありますが、Reactでも十分に活用できます。
最もシンプルな方法は、コンポーネントのプロパティとして依存性を渡すことです。
ただし、これはコンポーネントツリーが深くなるとバケツリレーが発生するため、実用的ではありません。
そこでReactの場合は以下の3つのアプローチが有効です。

  • カスタムフックの引数で注入するuseApi(apiAdapter)のように、フック自体がアダプタを受け取る設計にする。テスト時はスタブを渡し、本番時はデフォルトで本実装を使う
  • React Contextでグローバルに提供するApiProviderを作成し、その配下のコンポーネントはuseContextでアダプタを取得する。テストではApiProviderをスタブ実装でラップする
  • デフォルト引数+オーバーライド方式:フックの引数にデフォルトで本実装を設定し、テスト時のみオーバーライドできるようにする

これらの手法の共通点は、モックライブラリ(jest.mock)に頼らず、純粋なJavaScriptのオブジェクト置き換えで依存性を制御する点です。
これにより、型システムの恩恵をフルに受けられ、リファクタリング時のエラーもコンパイル時に検出できます。

アダプタ導入がもたらす副次的な効能

アダプタパターンとDIは、テストのためだけのテクニックではありません。
導入することで、以下のような本質的な設計改善が得られます。

  • 関心の分離:UIレンダリングとデータ取得の責任が明確に分かれるため、コンポーネントの可読性が向上します
  • 変更への耐性:APIのエンドポイント変更や、axiosからfetchへの移行などが、アダプタ内部の修正だけで完了します
  • 並行開発の容易化:バックエンドが未完成でも、スタブアダプタを使ってフロントエンドの開発を先行できます

私が過去に関わったプロジェクトでは、このアダプタ導入により、統合テストのモック定義数が平均で70%削減され、テスト実行時間も20%短縮されました。
何より、テストが実装ではなく振る舞いを検証するようになり、リグレッションの発見率が明らかに向上しました。

導入の際の注意点と段階的アプローチ

とはいえ、既存の大規模コードベースにアダプタ層を一気に導入するのは現実的ではありません。
そこで推奨するのは段階的リファクタリングです。

  • まずは新規機能開発時にのみアダプタパターンを適用し、そのメリットをチームで体感する
  • 次に、既存のコンポーネントで外部APIを直接呼んでいる箇所を、アダプタ経由に切り替えるリファクタリングを週に1〜2箇所ずつ行う
  • すべての切り替えが完了したタイミングで、jest.mockを一掃し、テストをDIベースに書き換える

このプロセスを急ぎすぎると、テストが壊れやすくなりチームの信頼を失うため、変更のたびに既存テストがパスすることを確認しながら進めることが肝要です。
また、TypeScriptを使用している場合は、インターフェースを定義することでリファクタリングの安全性が大幅に高まります。

次の章では、このようにしてモックを適切に制限した上で、何をアサーションの対象にするべきか、具体的なユーザー行動ベースの検証手法に焦点を当てます。

実装詳細ではなくユーザー行動をアサーションせよ─画面遷移とUI状態の検証

モックしたAPI応答によって変化するボタンの活性状態と表示テキストをチェックするテスト実行画面

アダプタパターンと依存性注入によってモックを適切な範囲に限定できたら、次はテストのアサーションそのものを見直す必要があります。
多くの開発者が陥る落とし穴は、モック関数が正しい引数で呼ばれたか特定のメソッドが何回コールされたかといった、実装の詳細を検証してしまうことです。
これらはテストを書いた瞬間からリファクタリング耐性を著しく低下させます。
代わりに目指すべきは、ユーザーが実際に操作して知覚できる変化─画面に表示されるテキスト、ボタンの活性状態、URLの変更、フォームのエラーメッセージ─をアサーションの主軸に据えることです。

なぜUI状態と画面遷移が最適な検証対象なのか

ユーザーインターフェースの本質は、入力に対する視覚的フィードバックです。
ログインボタンをクリックしたら「ようこそ」と表示される、カートに商品を追加したらバッジの数字が増える、これらの振る舞いは実装がどう書かれていようと変わらない期待される結果です。
したがって、これらの変化を検証するテストは、内部の関数名やAPIクライアントの実装を変更しても壊れません。
結果として、テストは「仕様書」としての役割を果たし、リファクタリングの安全網として真価を発揮します。

さらに、UI状態をアサーションすることで、複数のモジュールが連携した結果を一度に検証できます。
例えば、API応答が成功した場合に非活性だった送信ボタンが活性化され、同時に成功メッセージが表示される─この一連の流れを一つのテストで確認すれば、各モジュールのモック呼び出しを個別に検証するよりも、はるかに高い信頼性が得られます。

具体的なアサーション手法─Testing Libraryの哲学に従う

Reactのテストでこの考え方を具現化するなら、Testing Libraryのファミリーが提供するクエリとアサーションが最適です。
このライブラリは「ユーザーが画面を見て操作する方法」で要素を取得することを推奨しており、getByRolegetByTextfindByPlaceholderTextなどのAPIが用意されています。
以下の例を見てください。

// 悪い例:モックの呼び出しを検証
expect(mockApi.saveOrder).toHaveBeenCalledWith({ productId: '123' });
expect(mockNavigate).toHaveBeenCalledWith('/success');

// 良い例:UIの変化を検証
const button = await screen.findByRole('button', { name: /注文確定/i });
expect(button).toBeDisabled(); // 送信中は非活性
await waitFor(() => {
  expect(screen.getByText('注文が完了しました')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('完了');

良い例では、注文完了後に表示されるテキストや見出し、ボタンの状態を検証しています。
これらはAPI応答の内容やルーティングの実装が変わっても、表示すべき情報が同じであればテストはパスし続けます。
また、waitForを用いて非同期のUI更新を待つことで、実際のユーザーがブラウザで体験する待機時間に近い形で検証できます。

画面遷移の検証はURLとページタイトルを中心に

React Routerなどのルーティングライブラリを使う場合、画面遷移の検証もモックの呼び出しではなく、ブラウザのURLとレンダリングされるコンテンツをチェックすることを推奨します。
具体的には、MemoryRouterを使ってテスト用のルーティング環境を用意し、遷移後のwindow.location.pathnameや、遷移先ページに固有のテキストが表示されているかをアサーションします。

  • URLベースの検証expect(memoryHistory.location.pathname).toBe('/dashboard')
  • コンテンツベースの検証expect(screen.getByText('ダッシュボード')).toBeInTheDocument()

この両方を組み合わせることで、ユーザーがブラウザのアドレスバーで確認できる情報と、実際に目にする見出しが一致していることを保証できます。
これなら、内部で使っているuseNavigatehistory.pushの実装詳細が変わっても影響を受けません。

エラーケースとローディング状態も同様に扱う

ユーザー行動ベースのアサーションは、正常系だけでなく異常系にも適用します。
APIエラーが発生した場合、ユーザーに表示されるエラーメッセージや再試行ボタンの有無が、テストの検証対象になります。

// エラー時のUI検証
await waitFor(() => {
  expect(screen.getByText('通信に失敗しました。再試行してください')).toBeInTheDocument();
});
const retryButton = screen.getByRole('button', { name: /再試行/i });
expect(retryButton).toBeEnabled();

また、ローディング中のスピナーやスケルトン表示も、findByを使って要素が出現することを確認すれば、非同期処理の進行状態を正しく検証できます。
これらの検証は、モックのステータスコードやエラーオブジェクトの内容を直接チェックするよりも、実際のユーザー体験に直結するため、テストの価値が格段に高まります。

実装詳細アサーションを完全に排除する必要はない

ここで注意すべきは、「モックの呼び出し検証を一切してはいけない」という極端な主張ではないことです。
例えば、外部サービスへの課金リクエストが確実に1回だけ送信されたかを検証するのは、ビジネス上重要な要件です。
そのような場合は、モック関数の呼び出し回数をアサーションしても構いません。
ただし、そのような検証は例外的であると認識し、通常のアサーションはUI状態に集中するというバランスが重要です。

私のチームでは、テストレビューの際に「このアサーションはユーザーが見えるものか?」という質問を必ず投げかけます。
回答が「いいえ」の場合、そのアサーションは再考を促すシグナルとします。
この習慣を数ヶ月続けるだけで、テストコードの品質が劇的に変化することを実際に経験しました。

次章では、このようなユーザー行動ベースのテストを支えるために、モックファクトリとセットアップ共通化という具体的な実装テクニックを掘り下げていきます。
テストコードの重複を減らし、保守性をさらに高める方法です。

モックファクトリとセットアップ共通化でテスト保守性を劇的に向上させる

共通モック定義を集約したセットアップファイルと、テストごとにオーバーライドするヘルパー関数のコード例

ここまで、モックを適切な範囲に限定し、ユーザー行動を中心にアサーションを設計する方法を解説してきました。
しかし、それでも完全にモックを排除できない外部依存性(APIクライアントやブラウザAPI)については、テストごとに個別にモックを定義するという非効率な習慣が残りがちです。
これがテストファイルを増やすごとに重複コードを生み、保守コストを押し上げます。
そこで導入すべきが、モックファクトリセットアップ共通化という二つの実践的手法です。
これらを使えば、モック定義を一元管理し、テストの可読性と変更耐性を飛躍的に高められます。

モックファクトリで型安全なデフォルト応答を用意する

モックファクトリとは、デフォルトの応答値を生成する関数をあらかじめ定義しておき、各テストケースでは必要な部分だけをオーバーライドする仕組みです。
例えば、ユーザー情報を返すAPIモックを考えます。

// モックファクトリの例
function createMockUser(overrides?: Partial<User>) {
  return {
    id: 'user-123',
    name: '田中 太郎',
    email: 'tanaka@example.com',
    role: 'user' as const,
    createdAt: new Date('2025-01-01'),
    ...overrides,
  };
}

// テスト内での使用
const user = createMockUser({ role: 'admin' });
apiAdapter.fetchUser.mockResolvedValue(user);

このファクトリを共通ファイルに一度だけ定義しておけば、全テストで同じ構造のユーザーオブジェクトを使い回せます。
しかも、User型の定義が変更された場合(例:roleが文字列リテラルから列挙型に変わった)、ファクトリ関数のシグネチャを修正するだけで全テストに変更が反映されます。
各テストファイルで個別にハードコードされたオブジェクトを探し回る必要がなくなります。

同様に、API応答全体をモックするファクトリも用意すると効果的です。

function createMockApiResponse<T>(data: T, status = 200) {
  return {
    data,
    status,
    statusText: status === 200 ? 'OK' : 'Error',
    headers: { 'Content-Type': 'application/json' },
  };
}

これにより、エラーケースのテストも createMockApiResponse({}, 500) のように簡潔に記述でき、HTTPステータスコードの意味を毎回思い出す必要がなくなります。

セットアップ共通化でテストの前処理を集約する

モックファクトリと併用して強力なのが、テストセットアップの共通化です。
JestやVitestでは、beforeEachafterEachを使って各テストの前後処理を記述できますが、これらをテストファイルごとに書くのではなく、セットアップヘルパー関数として切り出します。

// test-utils/setup.ts
export function setupApiMocks() {
  const mockApi = {
    fetchUser: jest.fn(),
    saveOrder: jest.fn(),
    deleteItem: jest.fn(),
  };
  beforeEach(() => {
    jest.clearAllMocks();
    // デフォルトの成功応答を設定
    mockApi.fetchUser.mockResolvedValue(createMockUser());
    mockApi.saveOrder.mockResolvedValue({ success: true });
  });
  return mockApi;
}

テストファイルでは、このヘルパーを呼び出すだけで、全てのAPIモックが初期化され、デフォルト応答がセットされます。
個別のテストでは、必要に応じて mockApi.fetchUser.mockResolvedValueOnce(createMockUser({ role: 'guest' })) のようにオーバーライドするだけです。

このアプローチの利点は、新しいテストを書くときの認知的負荷が劇的に下がることにあります。
開発者は「モックをどう設定するか」ではなく「何をテストするか」に集中でき、テストの生産性が向上します。
また、共通セットアップを変更すれば全テストのデフォルト動作が変わるため、仕様変更時の波及作業も最小限に抑えられます。

セットアップファイル単位でのモジュールモック戦略

より大規模なプロジェクトでは、jest.mockによるモジュール単位のモックも、セットアップファイルに集約することを推奨します。
例えば、axioslocalStorageのモックは、setupTests.tsのようなグローバルセットアップファイルで一度だけ定義し、個別のテストファイルでは決して上書きしないというルールを設けます。

// setupTests.ts
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
mockedAxios.get.mockResolvedValue({ data: [] });
mockedAxios.post.mockResolvedValue({ data: { id: 1 } });

ただし、この方法はグローバルな副作用を持つため、テスト間の独立性を保つためにbeforeEachでリセットを必ず実行してください。
また、モジュールモックとファクトリベースのモックを混在させると混乱を招くため、チーム内で統一された戦略を決めておくことが重要です。

共通化がもたらす副作用とその対策

共通化には多くのメリットがありますが、同時に過度な抽象化がテストの可読性を損なうリスクも存在します。
モックファクトリが複雑化しすぎると、テストを読むだけでそのファクトリの定義を追う必要が生じ、かえって理解が遅れます。
そこで、以下のバランスを意識してください。

  • ファクトリは単一の責務に限定する(例:ユーザー用、注文用で別々に分ける)
  • デフォルト値は現実的かつ意味のあるものにする(name: 'test'ではなく、実際のドメインに即した値を使う)
  • オーバーライド用の引数は部分的(Partial)にし、必須項目は全てデフォルトでカバーする

また、共通セットアップはテストの意図を隠さないように注意します。
一部のテストではデフォルト応答を大幅に変更する必要がある場合、そのオーバーライドが一目でわかるようにコメントや変数名で明示しましょう。

導入の第一歩は「重複を見つけること」

既存のテストコードがある場合、まずは同一のモック定義が3箇所以上に出現しているパターンを洗い出します。
それらをファクトリに置き換え、徐々に共通セットアップへと移行します。
いきなり完璧な共通化を目指すのではなく、リファクタリングのたびに少しずつ改善する姿勢が長続きのコツです。

次の章では、モックとスパイの使い分けという、もう一つの重要な判断基準を扱います。
特に、呼び出し検証が必要なケースと、完全に不要なケースを明確に区別することで、テストの信頼性をさらに高める方法を解説します。

モックとスパイの使い分け─呼び出し検証よりも結果検証を優先する理由

モックの呼び出し回数ではなく、最終的なUIレンダリング結果を検証するテスト手法の比較表

テスト自動化の文脈で「モック」と「スパイ」はしばしば混同されますが、両者は明確に異なる役割を持ちます。
モックは依存性の振る舞いを置き換えて特定の応答を返させるためのもの、スパイは関数がどのように呼ばれたか(引数、回数、順序)を記録して後から検証するためのものです。
そして多くの開発者は、モックをスパイ代わりに使い、呼び出し検証を過剰に行う傾向があります。
本章では、この使い分けを誤るとテストがなぜ脆くなるのかを論理的に説明し、結果検証を優先すべき理由を実践的な観点から掘り下げます。

呼び出し検証がテストを実装依存にするメカニズム

スパイを用いた呼び出し検証は、テスト対象のコードが「どのように」処理を実行したかをチェックします。
例えば、「ログインボタンがクリックされたら、authService.login{ username, password }を引数に呼ばれる」という検証です。
一見すると妥当に思えますが、この検証は以下の理由でリファクタリング耐性を著しく損ないます

  • 関数名が変わるとテストが失敗する(例:loginsignIn
  • 引数の構造が変わると失敗する(例:オブジェクト→個別引数)
  • 呼び出し順序が変わると失敗する(例:バリデーション後に呼ぶ→並列に呼ぶ)
  • 内部で他の関数をラップするリファクタリングを行うと、呼び出し回数が変わる

これらの変更は、ユーザーにとって全く意味がありません
ログイン処理が正常に完了し、ユーザーがダッシュボード画面を表示できれば、内部の呼び出し方法がどう変わろうと問題ないはずです。
にもかかわらず、スパイによる検証がテストを赤くするため、開発者は無意味なテスト修正に時間を割くか、最悪の場合テストを削除します。

結果検証がもたらす抽象度の高いアサーション

対照的に、結果検証は「何が起こったか」に焦点を当てます。
同じログイン処理を例にとると、検証すべきは以下のようなユーザー体験の変化です。

  • ログインボタンが非活性になり、スピナーが表示される(ローディング状態)
  • 成功後に「ログインしました」というトーストメッセージが出現する
  • URLが/dashboardに変更される
  • 画面上部のユーザー名表示が入力したものに変わる

これらの検証は、内部の実装を一切知らなくても書けますし、実装が大幅に変わっても壊れません。
つまり、テストが仕様書として機能し、新しいチームメンバーがテストを読むだけで「この機能は何をするのか」を理解できます。

スパイが例外的に有効なケース

では、スパイや呼び出し検証が完全に不要かというと、そうではありません。
以下のようなビジネス上クリティカルな副作用を確実に発生させる必要があるケースでは、スパイが適切です。

  • 課金APIが必ず1回だけ呼ばれることを保証したい(二重課金防止)
  • 外部のメール送信サービスに正しいテンプレートIDが渡されていることを確認したい
  • アナリティクスイベントが正しいパラメータで発火されていることを検証したい
  • キャッシュクリア処理が特定の条件下で確実に実行されることを担保したい

これらの検証は、ユーザーインターフェースだけでは確認できない重要な副作用であり、モック関数のtoHaveBeenCalledTimestoHaveBeenCalledWithを使う正当な理由になります。
しかし、そのような検証はテスト全体のごく一部に限定すべきで、すべてのアサーションを呼び出しベースにすると、冒頭で述べた脆さが一気に表面化します。

モックとスパイの物理的な分離戦略

実務上のベストプラクティスとして、モックは応答のスタブに専念させ、スパイは必要な副作用の監視にのみ使うという役割分担を明確にします。
具体的には、以下のようなルールをチームで合意すると効果的です。

  • jest.fn() で作成したモック関数は、デフォルトではアサーションに使わない(スタブ値としてのみ利用)
  • スパイが必要な場合のみ、jest.spyOn(object, methodName) を明示的に使い、テストの末尾で expect(spy).toHaveBeenCalled... を記述する
  • スパイを使うテストには、コメントで「副作用検証のため」と理由を明記する

この分離により、テストコードを読んだ人が「これは結果検証か、副作用検証か」を即座に判断でき、レビューやメンテナンスの効率が向上します。

結果検証への移行がもたらす文化的変化

私が関わったチームでは、この方針を導入したことで、テストの失敗理由が「UIが想定と違う」か「外部副作用が正しくない」かの二択に明確化されました。
以前のように「モックの設定をミスした」「呼び出し順序が変わった」といったノイズが減り、本質的なバグに集中できるようになりました。
また、テストを書く開発者が「ユーザーが何を体験するか」を最初に考えるようになり、結果としてプロダクト自体のUX設計も洗練されていきます。

とはいえ、既存のテスト群を一気に書き換えるのは現実的ではありません。
次章では、モックの数を定量的に監視するというメトリクスベースのアプローチを提案します。
これにより、チーム全体でモックの乱用を可視化し、継続的に改善していく仕組みを構築する方法を解説します。

統合テストでモック数を定量的に監視する─過剰モック検出のためのメトリクス

テストファイルごとのモック定義数やアサーション種別を可視化するダッシュボードのイメージ

ここまで、モックの適切な範囲設定、アダプタパターンによる設計改善、ユーザー行動ベースのアサーション、そしてモックファクトリとスパイの適正利用について解説してきました。
しかし、これらのプラクティスをチームで徹底するには、定量的なフィードバックループが不可欠です。
人間の判断だけでは、どうしても「今回は例外」というモックの追加が積み重なり、気づけば再び乱用状態に逆戻りします。
そこで本章では、モック数を数値として監視し、過剰なモックを自動的に検出するためのメトリクス設計と、その導入方法を提案します。

モック過剰を示す3つの主要メトリクス

過剰なモックを客観的に評価するために、以下の3つのメトリクスを定期的に計測することを推奨します。

  • テストあたりのモック定義数(Mock Density):各テストファイル内で jest.mockjest.fn() が何回出現するかをカウントします。目安として、統合テスト1件あたりのモック定義は平均で3つ以下を目標にします。これを超える場合は、アダプタ層の導入やテストの分割を検討します
  • モックされたモジュールの割合(Mock Coverage Ratio):プロジェクト内で使用している外部モジュール(axios, localStorage, 日時ライブラリなど)のうち、テストでモックされているものの割合です。理想は20%未満です。これが高い場合、内部ロジックまで過剰にモックしている可能性があります
  • スパイ検証の割合(Spy Assertion Ratio):全アサーションのうち、toHaveBeenCalled 系のマッチャーを使っている割合です。この数値が30%を超えると、実装詳細への依存が強すぎるシグナルです。ユーザー行動ベースのアサーション(toBeInTheDocumenttoHaveTextContent など)を増やす方向に是正します

これらのメトリクスは、CIパイプラインで自動収集し、週次でレポート化することをお勧めします。
数値が悪化した場合にアラートを出す仕組みまで作れば、チーム全体でモックの質を可視化できます。

モック数の異常値を検出するしきい値設計

メトリクスを取るだけでは不十分で、異常値を自動検出するしきい値を設定する必要があります。
以下のようなルールを導入すると効果的です。

メトリクス 警告しきい値 危険しきい値 推奨アクション
テストあたりモック定義数 平均4以上 平均6以上 アダプタ導入またはテスト分割
モックされたモジュール率 30%以上 50%以上 内部ロジックのモックを廃止
スパイ検証割合 40%以上 60%以上 アサーションをUI状態中心に変更

しきい値はプロジェクトの規模やドメインによって調整が必要ですが、最初はこの数値をベンチマークとしてスタートし、チームの合意を得ながら微調整していくのが現実的です。

メトリクス収集を自動化するツールと手法

JavaScript/TypeScriptエコシステムでは、以下のようなツールを組み合わせてメトリクス収集を実現できます。

  • ESLint カスタムルールjest.mock の出現回数をカウントするルールを作成し、--max-warnings オプションでCIを失敗させる
  • Jest カスタムレポーター:テスト実行後に各ファイルのモック定義数とアサーション種別を集計し、JSONファイルとして出力する
  • GitHub Actions のアノテーション:新規追加されたモックがしきい値を超えた場合、PRにコメントを自動投稿する

例えば、JestのtestResultsProcessorを使って、各テストスイートのmockFnCallsassertionCountを集計するカスタムレポーターを実装すれば、ほぼリアルタイムでメトリクスを把握できます。
これらを導入する初期コストはありますが、長期的なテスト健全性を維持するための投資として十分に価値があります。

メトリクス悪化時の具体的な改善アクション

数値が警告ゾーンに入ったら、以下のアクションを段階的に実行します。

  • まずは新規テストに対して改善ルールを適用し、既存テストは段階的にリファクタリングする
  • モック定義数が突出して多いファイルを特定し、アダプタパターン導入の優先候補とする
  • スパイ検証が多いテストをピックアップし、UIベースのアサーションに書き換えるペアプログラミングセッションを開催する
  • 改善後のメトリクスをPRのコメントで提示し、チームに進捗を可視化する

重要なのは、メトリクスを「評価」ではなく「改善のための指標」として扱うことです。
数値が悪いことを責めるのではなく、どうすれば良くなるかを建設的に議論する文化を育てることが、長続きの鍵です。

メトリクス導入で得られる副次的な効果

この定量的アプローチを導入したチームでは、単にモックが減るだけでなく、コードレビューの質も向上しました。
レビュアーが「このテスト、モック数が平均より多いけど、本当に必要?」と具体的な数値ベースで指摘できるようになり、議論が抽象論から具体論へと変わります。
また、新メンバーのオンボーディング時にも「このメトリクスを基準にテストを書いてください」という明確なガイドラインを示せるため、属人化を防げます。

次章では、これらの知見をすべて総括し、モックを必要最小限に抑え、テストを真の仕様書として機能させるための最終チェックリストを提示します。
これまでの内容を実践に移すための具体的なアクションプランをまとめます。

まとめ─モックは必要最小限に、テストは振る舞いを語る仕様書へ

モックを抑制し本番に近い信頼性を得たReactテストコード全体の俯瞰図とチェックリスト

ここまで6つの章にわたって、React統合テストにおけるモックの適正な扱い方について、根本原理から具体的な実装手法、さらには定量的な監視方法までを論じてきました。
最終章となるここでは、これまでの内容を総括し、実践に移すための具体的なアクションプランを提示します。
モックは決して悪者ではなく、適切に使えば強力な武器です。
しかし、その使い方を誤ればテストスイート全体を腐敗させる毒にもなります。
その境界線を明確に理解し、チーム全体で継続的に改善していくための指針をまとめます。

再確認すべき3つの基本原理

まず、全章を通じて貫いてきた3つの基本原理を再確認します。

  • モックは「制御不能な外部」と「非決定性処理」にのみ限定する。内部ロジックやカスタムフックは実装のままテストし、モックがテストの主役になることを許さない
  • アサーションはユーザーが知覚できる変化に集中する。モックの呼び出し検証は例外的な副作用監視に留め、画面表示やURL、ボタン状態を主たる検証対象とする
  • モック定義はファクトリと共通セットアップで一元管理する。テストごとの重複を排除し、仕様変更時の修正コストを劇的に下げる

これらの原理は、いずれも「テストを実装ではなく振る舞いの仕様書として機能させる」という一貫した目的に収束します。
テストが仕様書であれば、新しいチームメンバーはテストを読むだけでシステムの振る舞いを理解でき、リファクタリングはテストに守られて安心して実行できます。

今すぐ始められる段階的な改善ロードマップ

既存のコードベースがモックだらけで、一朝一夕に全てを改善するのは非現実的です。
そこで、以下のフェーズに分けたロードマップを提案します。

  • フェーズ1(1〜2週間):新規機能のテストから、アダプタパターンとユーザー行動ベースのアサーションを導入する。同時に、モックファクトリを1つ作成し、既存テストでも使い始める
  • フェーズ2(1ヶ月):最もモック定義数が多いテストファイルをピックアップし、アダプタ経由にリファクタリングする。スパイ検証をUIアサーションに書き換える作業を週に2〜3件実施する
  • フェーズ3(2ヶ月目):メトリクス収集の自動化を導入し、CIでモック密度とスパイ検証割合を可視化する。週次レビューで数値を確認し、改善が停滞していないかをチェックする
  • フェーズ4(3ヶ月目以降):全テストのモック定義数が目標しきい値(平均3つ以下)に収まったら、次はテスト実行速度やカバレッジの質など、次の品質指標へと改善サイクルを拡張する

このロードマップの鍵は、一度に全てを変えようとしないことです。
小さな成功体験を積み重ねることで、チームの抵抗感を和らげ、持続可能な改善習慣を育てられます。

チーム文化としてのテスト品質の育て方

技術的なプラクティスだけでなく、テスト品質をチームの共通言語にする文化が重要です。
以下の習慣を日常に組み込むことをお勧めします。

  • テストレビューのチェックリストに「モックは必要最小限か」「アサーションはUI状態を検証しているか」を追加する
  • 月に一度の勉強会で、実際のテストコードを例にリファクタリングのデモを行う
  • モックを追加する際は、その理由をコードコメントに必ず記述する(例:// 外部決済APIはテスト環境で呼べないためモック
  • メトリクスレポートをチームダッシュボードに掲示し、数値の推移を可視化する

これらの取り組みは、単なるルールの押し付けではなく、「なぜこのプラクティスが大切なのか」を納得感を持って共有することが成功の分かれ目です。
私自身、複数のプロジェクトでこの文化醸成を経験しましたが、最初は反発があっても、テスト修正の手間が明らかに減った段階でチームの理解が一気に深まるものです。

最後に─モックは道具、テストは資産

モックはあくまで道具であり、それ自体が目的ではありません。
本当の目的は、信頼できるテストスイートを構築し、プロダクトの品質を継続的に担保することです。
モックを減らすことは、テストを書く手間を増やすのではなく、むしろ本質的な検証に集中するための最適化です。
ユーザーが実際に体験するフローをテストで再現し、その結果を検証する。
そのシンプルな原則に立ち返るだけで、テストは守るべきコストではなく、開発を加速する資産へと変わります。

本記事で示したプラクティスをすべて完璧に実装する必要はありません。
まずは一つ、例えば「新規テストではモックをAPIクライアントだけに限定する」というルールから始めてみてください。
その小さな一歩が、やがてコードベース全体のテスト文化を変えるきっかけになります。
皆さんのReactプロジェクトが、モックに振り回されない、堅牢で読みやすいテスト群に育つことを願っています。

コメント

タイトルとURLをコピーしました