測試中的模擬(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 = ();
}
在這裡,foo 是 main 的一個“自有程式碼”依賴。
為什麼
為了對 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
一個鮮為人知的事實是,有一種內建的方法來模擬 fetch。undici 是 fetch 的 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');
});
});
這在與靜態固定資料(已簽入程式碼倉庫)進行比較時特別有用,例如在快照測試中。