測試中的模擬(Mocking)

模擬(Mocking)是建立仿製品、傀儡的一種手段。這通常以 當 'a' 時,做 'b' 的方式進行操作。其思想是限制活動部件的數量,並控制那些“不重要”的東西。嚴格來說,“模擬(mocks)”和“存根(stubs)”是不同型別的“測試替身(test doubles)”。對於好奇的讀者,存根(stub)是一個什麼都不做(no-op)但會跟蹤其呼叫的替代品。模擬(mock)則是一個帶有虛假實現的存根(即 當 '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 出現問題時,其他許多測試也會失敗,從而使得追蹤問題變得更加繁瑣:如果只有最終導致問題的那個測試失敗,那麼問題就很容易被發現;而 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(, );
}

在上面的例子中,第一個和第二個測試用例(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

一個鮮為人知的事實是,有一種內建的方法來模擬 fetchundicifetch 的 Node.js 實現。它與 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(), {
      ,
      ,
    });
  });
});

時間

就像奇異博士一樣,你也可以控制時間。你通常這樣做只是為了方便,以避免人為延長測試執行時間(你真的想等 3 分鐘讓那個 setTimeout() 觸發嗎?)。你也可能想要穿越時間。這利用了 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');
  });
});

這在與靜態固定資料(已簽入程式碼倉庫)進行比較時特別有用,例如在快照測試中。