proxyquireでDI もっとモックテストしよう
こんにちは、@armorik83 です。みなさんテスト書いてますか。
今日は久々にテストの話を書きます。経緯を含めた流れで話を進めます。
ブラウザ向けライブラリを Node.js + Mocha でテストしたい
今、AngularJS 絡みのライブラリを開発中なんですが、そこでdocument
が出てくる点が問題となります。
// 開発中のソースを見本用にいじってます
sample(controller: any, requires?: any[]) {
requires = requires || [];
requires.push(this.id);
const selector = controller.selector;
const element = document.querySelector(selector); // この辺
this.angular.bootstrap(element, requires);
}
document
はグローバル変数で、これはwindow.document
と同じことです。ブラウザ上で動かす際はwindow
もwindow.document
も値があるので問題ないですが、このソースを Mocha で(Karma などを使わず)テストするときに、window
が未定義として落ちます。
ReferenceError: document is not defined
ひとまずソース内に直に書かない
import {document} from '../browser-dependencies'; // 追加
class Foo {
sample(controller: any, requires?: any[]) {
requires = requires || [];
requires.push(this.id);
const selector = controller.selector;
const element = document.querySelector(selector);
// このdocumentは'../browser-dependencies'の module.exports.document となる
this.angular.bootstrap(element, requires);
}
}
'use strict';
export var document: Document = window.document;
まだ密結合ですが、間に DI の余地を設けました。
ReferenceError: window is not defined
window
が残っているので怒られます。
isomorphic っぽい分岐
UMD (Universal Module Definition)の実装にあるような分岐でエラーを黙らせます。後述のスタブ化の際、これをしていないとスタブ化も出来なかったため(同じエラーが出たため)必須なようです。
'use strict';
export var document: Document = (typeof window === 'object')
? window.document
: (<Document>{}); // ブラウザでの動作時は{}が使われることはない
window
が存在しない場合に逃がせるようにしました。TypeScript の(<Document>{})
キャストが醜悪ですが、どうせここはスタブ化されるので目をつぶります。今回は AngularJS 向けライブラリで、Node.js では利用されることを想定していないため雑です。
proxyquire の出番
テスト界のライオンこと@t_wada 先生に教えてもらったproxyquireがもうとにかく便利だったので、ぜひとも広めたいと思ってます。今までオレオレ Injector を実装してrequire()
のスタブ化を実現していたんですが、もうその手間も無くなるので本当に嬉しい。
書き方
ちょっとクセがあったのでメモ。テストソースspec.js
があったとします。
import proxyquire from 'proxyquire';
const myLibrary = proxyquire('../../src/mylibrary', {
'./sub': {
greet: 'hi!'
}
});
import sub from './sub';
export var greet = 'hello!';
'../../src/mylibrary'
はspec.js
からみたmylibrary.js
のパスです。一方で'./sub'
は「mylibrary.js
からみた」sub.js
のパスです。つまりfrom './sub';
の文字列と同じものが入ります(.js
の有無など表記ぶれはダメ)。
{}
にはスタブなど、テスト時にimport sub from './sub';
に取得させるオブジェクトを指定します。
2 段階の DI
具体的な話にします。現在このようなファイル構成があったとします。mylibrary-spec.es6
はmylibrary.ts
をテストしようとしています。
.
├── src
│ ├── browser-dependencies.ts
│ ├── mylibrary.ts
│ └── sub
│ └── sub.ts
└── test
└── unit
└── mylibrary-spec.es6
'use strict';
import sub from './sub/sub';
// 略
'use strict';
import {document} from '../browser-dependencies';
class Sub {
// 略
sample(controller: any, requires?: any[]) {
requires = requires || [];
requires.push(this.id);
const selector = controller.selector;
const element = document.querySelector(selector);
this.angular.bootstrap(element, requires);
}
// 略
}
export default Sub;
'use strict';
export var document: Document = (typeof window === 'object') ? window.document : (<Document>{});
document
を必要とする実装はsub.ts
内だけです。テスト内のproxyquire
は次のように書きます。
const mylibrary = proxyquire('../../src/mylibrary', {
'./sub/sub': proxyquire('../../src/sub/sub', {
'../browser-dependencies': {
document: mock.document
}
})
});
この書き方に辿り着くまで小一時間ハマったのですが、なんとか理想の動きをしてくれました。
'../../src/mylibrary'
はテストから見たmylibrary
のパス、'./sub/sub'
はmylibrary
から見たsub
のパスです。
次がポイントで、多段 DI の際は'../../src/sub/sub'
とまたテストから見たsub
のパスを書きます。テスト側であらゆる proxy を全て生成してしまうのです。開発元のサンプルにこのケースが載ってなく、えらい迷ってしまいました。
こうするとテスト時にmylibrary
が依存しているsub
は、ちゃんとモックのdocument
を使ってくれます。
sinon と合わせて使う
実際はこのモックを使って検証していくので、sinonを併用します。sinon を使ったテスト例です。assert はもちろんpower-assertですよ。
import assert from 'power-assert';
import sinon from 'sinon';
import proxyquire from 'proxyquire';
/* モックの作成 */
const mock = {
angular: {
bootstrap: () => {}, // モックだからてきとう
version: {full: '1.3.14', major: 1, minor: 3, dot: 14}
},
document: {
querySelector: () => {}
}
};
/* スタブの定義 */
const stub = {
angular: {
bootstrap: sinon.stub(mock.angular, 'bootstrap')
},
document: {
// テスト中は一律で'element'を返すように定義
// querySelector自体にバグがあっても私の責任ではないのだ…
querySelector: sinon.stub(mock.document, 'querySelector').returns('element')
}
};
const m = proxyquire('../../src/mylibrary', {
'./sub/sub': proxyquire('../../src/sub/sub', {
'../browser-dependencies': {
document: mock.document // ここでモックを注入
}
})
});
const mylibrary = m.staticFunc(mock.angular);
describe('My Library', () => {
describe('bootstrap', () => {
it('should be called an AngularJS original bootstrap', () => {
const controller = {};
controller.selector = 'selector';
mylibrary.bootstrap(controller); // ここでsub.tsが呼ばれると思ってください
assert(stub.angular.bootstrap.callCount === 1); // たしかに呼ばれた!
});
});
});
今回 proxyquire はとても強力な即戦力で、いきなり惚れました。sinon は前からずっと使ってますがこれもめちゃ便利ですよ。
それでは快適なテストライフを。
Discussion