モジュールモッキング
モジュールモッキングは、テスト対象のモジュールがインポートする別のモジュールの一部または全部を、関係するモジュールの協力なしに、テストが置き換えるテスト技法です。ほとんどの場合、依存性注入はモジュールモッキングよりも優れた選択肢です。しかし、どうしてもモジュールモッキングを行いたい場合は、Jasmineが使用されるほとんどの環境で可能です。
モジュールモッキングの利点と欠点
モジュールモッキングの最大の利点は、依存関係と密結合したコードを簡単にテストできることです。これは、特に、テスト容易性を考慮せずに設計されたレガシーコードをテストする場合や、ハードワイヤードされた依存関係を好む場合に非常に便利です。
モジュールモッキングの最大の欠点は、同じく依存関係と密結合したコードを簡単にテストできることです。結果として、テストを作成するだけでは、過剰な結合に関するフィードバックが得られなくなります。
モジュールモッキングのもう一つの大きな欠点は、テスト対象のコードが依存するグローバル状態を変更することです。これにより、テストは本質的に不安定になります。モックされたモジュールと対話する各テストは、後続のテストの動作に影響を与えます。モックをテスト間で元の構成にリセットしない限り、この影響は避けられません。
モジュールモッキングは、JavaScript言語の「流れに逆らう」ものでもあります。あるファイルが、別のファイルのグローバル変数に見えるものを、そのファイルの知識や関与なしに、変更することを伴います。これは、JavaScriptの他の場所では発生しないため、混乱を招く可能性があります。また、モッキング技法がモジュールシステムまたは言語自体の仕様と競合する場合に問題が発生する可能性があります。
多くの環境でのモジュールモッキングは、Node、トランスパイラー、またはバンドラーの不安定なAPIまたはプライベートな実装の詳細に依存します。そのため、将来的に動作しなくなるリスクが大幅に高まります。
それでもモジュールモッキングを使用する場合
役立つかもしれないいくつかの方法を紹介します。ほとんどの方法には、ローカルで実行できる完全な動作例へのリンクが含まれています。
適切な方法を選択するには、コードがどのようにコンパイル、バンドル、ロードされるかについて少し知る必要があります。ほとんどの場合、重要なのはNodeまたはブラウザに実際にロードされるコードの種類です。そのため、たとえば、コードがCommonJSモジュールにコンパイルされる場合は、ソースコードにimport
ステートメントが含まれていても、CommonJSモジュールモッキングアプローチが必要です。
特に指定のない限り、これらの方法はすべて、Webpackまたはその他のバンドラーを使用していないことを前提としています。
- jasmine-browser-runnerを使用したブラウザのESモジュール
- 追加ツールなしのNodeのCommonJSモジュール
- 追加ツールなしのNodeのCommonJS出力を持つTypeScript
- Testdouble.jsを使用したNodeのCommonJSモジュール
- Testdouble.jsを使用したNodeのESモジュール
- Webpack
- Angular
jasmine-browser-runnerを使用したブラウザのESモジュール
コードが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モジュール
Nodeで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のCommonJS出力を持つTypeScript
ほとんどのバージョンのTypeScriptは、モジュールを分割代入しないCommonJSコードを生成します。そのため、このソースコード
import {theString} from './anotherModule';
export function quote() {
return '"' + theString() + '"';
}
は、次のようなコードにコンパイルされます。
const anotherModule_1 = require("./anotherModule");
function quote() {
return '"' + (0, anotherModule_1.theString)() + '"';
}
これにより、上記の「追加ツールなしのNodeの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では、エクスポートされたプロパティが読み取り専用としてマークされるため、機能しません。
Testdouble.jsを使用したNodeのCommonJSモジュール
Testdouble.jsは、Jasmineスパイの代替手段を提供するだけでなく、Nodeモジュールローダーにフックしてモジュールをモックに置き換えることができます。
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のドキュメントを参照してください。
Testdouble.jsを使用したNodeのESモジュール
TestdoubleはESモジュールもモックできます。上記のCommonJSの方法とは2つの重要な違いがあります。1つ目は、TestdoubleローダーをNodeコマンドラインで指定する必要があることです。npx jasmine
または./node_modules/.bin/jasmine
を実行する代わりに、node --loader=testdouble ./node_modules/.bin/jasmine
を実行します.2つ目の違いは、仕様では、require
または静的import
ステートメントではなく、非同期動的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.json
ではなくjasmine.js
である必要があります。Jasmine 5.1.0以降では、Testdouble ESMローダーでJSまたはJSON構成ファイルのいずれかを使用できます。
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マニュアル、特にテストと依存性注入のセクションにあります.
このガイドへの貢献
このガイドで説明されていない環境でモジュールモッキングを有効にする方法を知っていますか?追加情報を提供してください。完全な動作例は、構成、パッケージバージョンなどの詳細を示しているため、特に貴重です。これらの詳細は、最初は明らかではない方法で重要になる可能性があります。