注意:本指南處理 JavaScript 生態系統中快速變更的部分,可能會過時。最後更新於 2023 年 9 月。本文描述的一些做法和提及的工具仰賴於不穩定的 API、私人 API 或 Node、套件管理工具、轉譯編譯器等實作細節,未來可能會變更。

模組仿冒

模組仿冒是一種測試技術,測試會替換匯入另一個模組的某一模組的部分或全部,而不需要任一相關模組的配合。在多數情況,相依性注入是比模組仿冒更好的選擇。但是,如果您真的想這麼做,Jasmine 使用的大部分環境都能做到。

模組仿冒的優缺點

模組仿冒最大的優點是它讓您可以輕鬆測試與其相依性緊密結合的程式碼。這可能很方便,特別是如果您要測試未考量可測試性所設計的舊程式碼,或者您決定偏好硬連線相依性時。

模組仿冒最大的缺點是它讓您可以輕鬆測試與其相依性緊密結合的程式碼。因此,撰寫測試的行為將不再針對過度結合提供意見。

模組仿冒的另一個主要缺點是它會變更測試中的程式碼所依賴的全球狀態。這讓測試預設就不可靠:與仿冒模組互動的每個測試都會影響後續測試的行為,除非在測試之間將仿冒重設為其原始組態。

模組仿冒也「違反了準則」的 JavaScript 語言。它會讓一個檔案變異看起來是另一個檔案的全球變數,而沒有該檔案的知識或介入。這可能會令人困惑,因為這種情況並未發生於 JavaScript 中的任何其他地方。在仿冒技術與模組系統或語言本身的規格有衝突的情況中,它也會造成問題。

在許多環境中,模組仿冒會涉及不穩定的 API 或 Node、轉譯編譯器或套件管理工具的私人實作細節。這會大幅增加未來可能停止運作的風險。

如果您仍想使用模組仿冒

以下是可能會有所幫助的一些方法。其中多數包含您可以本機執行的完整運作範例連結。

若要選擇正確的方法,您需要略微了解程式的編譯、套件管理和載入方式。在多數情況下,重要的是實際載入 Node 或瀏覽器中的程式碼類型。因此,舉例來說,如果您的程式碼編譯為 CommonJS 模組,您需要一種 CommonJS 模組仿冒方法,即使原始碼包含 import 陳述式也是如此。

除非另有說明,所有這些方法都假設您沒有使用 Webpack 或任何其他套件管理工具。

瀏覽器中的 ES 模組使用 jasmine-browser-runner

如果您的程式採用 ES 模組,且您使用 jasmine-browser-runner 進行測試,您可以使用 匯入地圖 來模擬模組。匯入地圖會覆寫瀏覽器預設的模組解析度,讓您可以替換模擬版本。舉例而言,假設您在 src/anotherModule.mjs 中有一個「實際」模組,而在 mockModules/anotherModule.mjs 中有一個模擬版本,您可以藉由這個設定,載入模擬版,而不是實際版本。

// jasmine-browser.json
{
  "srcDir": "src",
  // ...
  "importMap": {
    "moduleRootDir": "mockModules",
    "imports": {
      "anotherModule": "./anotherModule.mjs"
    }
  }
}
// src/anotherModule.mjs
export function theString() {
    return 'the string';
}
// mockModules/anotherModule.mjs
export let theString = jasmine.createSpy('theString');

// IMPORTANT:
// Reset after each spec to prevent spy state from leaking to the next spec
afterEach(function() {
    theString = jasmine.createSpy('theString');
});

好消息是,這項技術完全依賴於 ES 模組系統的標準功能,因此未來發生重大變動的可能性非常低。壞消息是,它完全是全域變數。您無法只在部分測試中模擬模組,或是在不同測試中使用不同的模擬。瀏覽器不提供模組載入器延伸功能,這會允許該種行為。

完整的運作範例

Node 中的 CommonJS 模組,不使用額外工具

如果您在節點中使用 CommonJS 模組,只要您不對它們解構,您就可以在沒有任何額外工具的情況下模擬它們。

// aModule.js
// Destructuring (e.g. const {theString} = require('./anotherModule.js');) will
// prevent code outside this file from replacing toString.
const anotherModule = require('./anotherModule.js');

function quote() {
    return '"' + anotherModule.theString() + '"';
}

module.exports = { quote };
// aModuleSpec.js
const anotherModule = require('../anotherModule');
const subject = require('../aModule');

describe('aModule', function() {
    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            // Spies installed with spyOn are automatically cleaned up by
            // Jasmine between tests.
            spyOn(anotherModule, 'theString').and.returnValue('a more different string');
            expect(anotherModule.theString()).toEqual('a more different string');
            expect(subject.quote()).toEqual('"a more different string"');
        });
    });
});

這會對程式碼編寫方式施加限制,因為如果 aModule 解構 anotherModule,它將無法運作。但它不需要任何額外的工具,而且由於模擬是透過 spyOn 執行的,您可以依賴 Jasmine 在測試結束時自動清除它。

完整的運作範例

Node 中的 TypeScript 具有 CommonJS 輸出,不使用額外工具

此秘訣依賴於在 TypeScript 編譯器輸出的未記錄明細,這些細節在過去已經改變過,也可能在未來改變。已使用 TypeScript 5.1.0 進行測試。

大多數版本的 TypeScript 都會發出不會解構模組的 CommonJS 程式碼。因此這個原始程式碼

import {theString} from './anotherModule';

export function quote() {
    return '"' + theString() + '"';
}

編譯為像這樣

const anotherModule_1 = require("./anotherModule");
function quote() {
    return '"' + (0, anotherModule_1.theString)() + '"';
}

這樣一來,即使原始程式碼解構模組,上面「沒有額外工具的節點中的 CommonJS 模組」秘訣中所述的方法也能運作。

// aModule.ts
import {theString} from './anotherModule';

export function quote() {
    return '"' + theString() + '"';
}
// aModuleSpec.ts
import "jasmine";
import {quote} from '../src/aModule';
import * as anotherModule from '../src/anotherModule';

describe('aModule', function() {
    describe('quote', function() {
        it('quotes the string returned by theString', function() {
            spyOn(anotherModule, 'theString').and.returnValue('a more different string');
            expect(quote()).toEqual('"a more different string"');
        });
    });
});

這對任何會解構匯入模組的 TypeScript 版本都無效。它在 TypeScript 3.9 中也不適用,因為該版本標示匯出的屬性為唯讀。

完整的運作範例

Node 中的 CommonJS 模組使用 Testdouble.js

除了提供 Jasmine 間諜的替代方案之外,Testdouble.js 也可以連接到節點模組載入器,並用模擬替換模組。

const td = require('testdouble');

describe('aModule', function() {
    beforeEach(function () {
        this.anotherModule = td.replace('../anotherModule.js');
        this.subject = require('../aModule.js');
    });

    afterEach(function () {
        td.reset();
    });

    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            td.when(this.anotherModule.theString()).thenReturn('a more different string');
            expect(this.subject.quote()).toEqual('"a more different string"');
        });
    });
});

如果您偏好使用 Jasmine 間諜,您也可以這麼做。

const td = require('testdouble');

describe('aModule', function() {
    beforeEach(function () {
        this.anotherModule = td.replace(
            '../anotherModule.js',
            {theString: jasmine.createSpy('anotherModule.theString')}
        );
        this.subject = require('../aModule.js');
    });

    afterEach(function () {
        td.reset();
    });

    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            this.anotherModule.theString.and.returnValue('a more different string');
            expect(this.subject.quote()).toEqual('"a more different string"');
            expect(this.anotherModule.theString).toHaveBeenCalled();
        });
    });
});

請參閱 Testdouble 的文件 以取得更多資訊。

完整的運作範例

Node 中的 ES 模組使用 Testdouble.js

此秘訣依賴於 節點模組載入器 API,截至節點 20.6.1,它仍為試驗性質。在未來版本的節點中,可能會包含對載入器 API 的重大變更。

Testdouble 也可以模擬 ES 模組。與上述 CommonJS 秘訣有兩個重要的差異。其一是 Testdouble 載入器必須在節點命令行上指定。因此,請勿執行 npx jasmine./node_modules/.bin/jasmine,而是執行 node --loader=testdouble ./node_modules/.bin/jasmine。其二的差異是,規格必須透過非同步動態 import() 載入模組,而不是透過 require 或靜態 import 陳述式。

import * as td from 'testdouble';

describe('aModule', function() {
    beforeEach(async function () {
        this.anotherModule = await td.replaceEsm('../anotherModule.js');
        this.subject = await import('../aModule.js');
    });

    afterEach(function () {
        td.reset();
    });

    describe('quote', function () {
        it('quotes the string returned by theString', function () {
            td.when(this.anotherModule.theString()).thenReturn('a more different string');
            expect(this.subject.quote()).toEqual('"a more different string"');
        });
    });
});

與上面的 CommonJS 秘訣相似,如果您偏好,您也可以使用 Jasmine 間諜。

由於 Testdouble 中的臭蟲與舊版 Jasmine 中的臭蟲產生交互作用,如果您將 Testdouble ESM 載入器與 Jasmine 5.0.x 或更早版本一起使用,您的 Jasmine 設定檔必須為 jasmine.js,而不是 jasmine.json。Jasmine 5.1.0 和更新版本允許將 JS 或 JSON 設定檔與 Testdouble ESM 載入器一起使用。

完整的運作範例,使用 JavaScript
使用 TypeScript 的完整範例

Webpack

Rewiremock 是可在各種情形中用來模擬模組的套件,包含 Webpack 彙整程式碼時。Rewiremock 有許多不同的設定方式。更多資訊請參閱其 README

Angular

Angular 測試應該使用 Angular 對相依性注入的強大支援,而非嘗試模擬模組的屬性。啟用模組模擬可能需要修正 Angular 編譯器(或重寫其輸出),以將匯出的屬性標示為可寫。目前沒有任何已知的工具可執行此作業。就算有,將來的 Angular 版本也可能會中斷這些工具。

如果你真的想要在 Angular 模擬一個硬體連線的相依性,你可以透過匯出一項目由你控制的包裝器物件來處理模組系統。

// foo.js
const wrapper = {
    foo() { /* ... */ }
}
// bar.js
import fooWrapper from './foo.js';
//...
fooWrapper.foo();
// bar.spec.js
import fooWrapper from '../path/to/foo.js';
import bar from '../path/to/bar.js';
// ...
it('can mock foo', function() {
    spyOn(fooWrapper, 'foo').and.callFake(function() { /*... */ });
    // ...
})

關於測試 Angular 應用程式,可在 Angular 手冊中找到更多相關資料,尤其是測試相依性注入的部分。

協助撰寫本指南

你知道如何在這個指南未涵蓋的環境中啟用模組模擬嗎?請協助增補。完整的範例特別有價值,因為它們會顯示可能以不顯而易見的方式影響結果的設定詳細資料、套件版本等等。