テストでのモック

モックとは、模倣品、つまり操り人形を作成する手段です。これは通常、「'a' のとき、'b' をする」という形で人形を操ります。この考え方は、動く部分の数を制限し、「重要でない」ものを制御することです。「モック」と「スタブ」は技術的には異なる種類の「テストダブル」です。興味のある方のために説明すると、スタブは何もしない(no-op)が呼び出しを追跡する代替品です。モックは、偽の実装も持つスタブです(「'a' のとき、'b' をする」)。このドキュメント内では、その違いは重要ではなく、スタブもモックと呼びます。

テストは決定論的であるべきです。つまり、どんな順序で、何回実行しても、常に同じ結果を生成するべきです。適切なセットアップとモックがこれを可能にします。

Node.js は、コードのさまざまな部分をモックするための多くの方法を提供しています。

この記事では、以下の種類のテストについて扱います。

種類説明モックの候補
単体分離できる最小単位のコードconst sum = (a, b) => a + b自作コード、外部コード、外部システム
コンポーネント単体 + 依存関係const arithmetic = (op = sum, a, b) => ops[op](a, b)外部コード、外部システム
統合コンポーネントの組み合わせ-外部コード、外部システム
エンドツーエンド(e2e)アプリ + 外部データストア、デリバリーなど実際の外部システムに接続されたアプリを文字通り使用する偽のユーザー(例:Playwright エージェント)。なし(モックしない)

いつモックし、いつモックしないかについては、さまざまな考え方があり、その大まかな概要を以下に示します。

いつモックし、いつモックしないか

主なモック候補は3つあります。

  • 自作コード
  • 外部コード
  • 外部システム

自作コード

これはあなたのプロジェクトが制御するものです。

import  from './foo.mjs';

export function () {
  const  = ();
}

ここでは、foomain の「自作コード」の依存関係です。

なぜ

main の真の単体テストのためには、foo はモックされるべきです。あなたは main が動作することをテストしているのであり、main + foo が動作することをテストしているわけではありません(それは別のテストです)。

なぜしない

foo をモックすることは、特に foo が単純で、十分にテストされ、めったに更新されない場合、割に合わないことがあります。

foo をモックしない方が良い場合があります。なぜなら、より本物に近いものであり、foo のカバレッジも向上するからです(main のテストが foo も検証するため)。しかし、これはノイズを生み出す可能性があります。foo が壊れると、他の多くのテストも壊れるため、問題の追跡がより面倒になります。問題の最終的な原因である1つのテストだけが失敗している場合、それは非常に見つけやすいですが、100のテストが失敗していると、本当の問題を見つけるのは干し草の中から針を探すようなものです。

外部コード

これはあなたのプロジェクトが制御しないものです。

import  from 'bar';

export function () {
  const  = ();
}

ここでは、bar は外部パッケージ、例えば npm の依存関係です。

議論の余地なく、単体テストではこれは常にモックされるべきです。コンポーネントテストや統合テストでは、モックするかどうかはそれが何であるかによります。

なぜ

プロジェクトが管理していないコードが動作することを検証するのは、単体テストの目的ではありません(そして、そのコードは独自のテストを持つべきです)。

なぜしない

時には、モックすることが現実的でない場合もあります。例えば、React や Angular のような大規模なフレームワークをモックすることはほとんどありません(薬は病気よりも悪い結果をもたらすでしょう)。

外部システム

これらはデータベース、環境(Web アプリの場合は Chromium や Firefox、Node アプリの場合はオペレーティングシステムなど)、ファイルシステム、メモリストアなどです。

理想的には、これらをモックする必要はないでしょう。各ケースごとに分離されたコピーを作成する(通常、コストや追加の実行時間などの理由で非常に非現実的)以外では、次善の策としてモックがあります。モックがないと、テストが互いに妨害し合います。

import {  } from 'db';

export function (,  = false) {
  validate(, val);

  if () {
    return .getAll();
  }

  return .getOne();
}

export function (, ) {
  validate(, );

  return .upsert(, );
}

上記では、最初と2番目のケース(it() 文)は、同時に実行され同じストアを変更するため、互いに妨害し合う可能性があります(競合状態)。save() の挿入により、本来は有効な read() のテストが見つかったアイテムのアサーションで失敗する可能性があり(そして read()save() に対して同じことをする可能性があります)。

何をモックするか

モジュール + ユニット

これは Node.js テストランナーの mock を活用します。

import  from 'node:assert/strict';
import { , , ,  } from 'node:test';

('foo', { : true }, () => {
  const  = .();
  let ;

  (async () => {
    const  = await import('./bar.mjs')
      // discard the original default export
      .(({ default: , ... }) => );

    // It's usually not necessary to manually call restore() after each
    // nor reset() after all (node does this automatically).
    .('./bar.mjs', {
      : ,
      // Keep the other exports that you don't want to mock.
      : ,
    });

    // This MUST be a dynamic import because that is the only way to ensure the
    // import starts after the mock has been set up.
    ({  } = await import('./foo.mjs'));
  });

  ('should do the thing', () => {
    ..(function () {
      /* … */
    });

    .((), 42);
  });
});

API

あまり知られていない事実ですが、fetch をモックする組み込みの方法があります。undici は Node.js の fetch の実装です。これは node に同梱されていますが、現在は node 自体からは公開されていないため、インストールする必要があります(例:npm install undici)。

import  from 'node:assert/strict';
import { , ,  } from 'node:test';

import { ,  } from 'undici';

import  from './endpoints.mjs';

('endpoints', { : true }, () => {
  let ;
  (() => {
     = new ();
    ();
  });

  ('should retrieve data', async () => {
    const  = 'foo';
    const  = 200;
    const  = {
      : 'good',
      : 'item',
    };

    
      .get('https://example.com')
      .intercept({
        : ,
        : 'GET',
      })
      .reply(, );

    .(await .get(), {
      ,
      ,
    });
  });

  ('should save data', async () => {
    const  = 'foo/1';
    const  = 201;
    const  = {
      : 'good',
      : 'item',
    };

    
      .get('https://example.com')
      .intercept({
        : ,
        : 'PUT',
      })
      .reply(, );

    .(await .save(), {
      ,
      ,
    });
  });
});

時間

ドクター・ストレンジのように、あなたも時間を制御できます。これは通常、テスト実行が人為的に長引くのを避けるために行います(その setTimeout() がトリガーされるのを本当に3分待ちたいですか?)。また、時間を旅したい場合もあるでしょう。これは Node.js テストランナーの mock.timers を活用します。

ここではタイムゾーンの使用に注意してください(タイムスタンプの Z)。一貫したタイムゾーンを含めないと、予期せぬ結果につながる可能性があります。

import  from 'node:assert/strict';
import { , ,  } from 'node:test';

import  from './ago.mjs';

('whatever', { : true }, () => {
  ('should choose "minutes" when that\'s the closet unit', () => {
    ..({ : new ('2000-01-01T00:02:02Z') });

    const  = ('1999-12-01T23:59:59Z');

    .(, '2 minutes ago');
  });
});

これは、スナップショットテストのように、静的なフィクスチャ(リポジトリにチェックインされているもの)と比較する場合に特に便利です。