はじめに
以下の状況下で困っている方に向けた記事です
- es2015でbabelでトランスパイルすることを前提にテストコードを書いている
- テスト対象のモジュール[Foo]が別のモジュール[Bar]をimportしていて、[Bar]のインスタンス化をテストしたい
困っていたこと
※karmaでmocha, sinonを用いていることを前提としています
- exportされたクラスがimportされた場所毎に異なるコンストラクタ関数オブジェクトになっている
- そのせいでテスト対象のモジュールがimportしているコンストラクタ関数はspyか不可能
- 異なるコンテキストのコンストラクタ関数でもprototypeオブジェクトは共通らしいので、prototypeオブジェクトをmock化してconstructorメソッドをのっとればいいじゃん
- エンジンによってそれで良いパターンとだめなパターンがある
- 例) phantomJSではその方法でいけるが、chromeでアクセスするとだめ
- そもそもes2015のconstructorって・・・
- エンジンによってそれで良いパターンとだめなパターンがある
実際にコードでお見せします
import Bar from "../src/bar";
export default class Foo {
constructor() {
this.hoge = 'hoge';
}
fuga() {
const bar = new Bar();
bar.piyo();
}
}
export default class Bar {
constructor() {
this.fuga = 'fuga';
}
piyo() {
alert('piyo');
}
}
import assert from "power-assert";
import Foo form "../src/foo";
import Bar from "../src/bar";
describe("Fooクラスのテスト", () => {
it("fugaメソッドの呼び出しでBarクラスがインスタンス化される", () => {
// クラスBar(ES5までで言えばコンストラクタ関数Bar)は、
// FooクラスでimportされているBarとは異なる。だからspyはできない
// spyできたら楽
// const spy = sinon.spy(Bar);
// const foo = new Foo();
// foo.fuga();
// const isBarCalledWithNew = spy.calledWithNew();
// assert(isBarCalledWithNew); // => expected true, but false returns
// しかしprototypeオブジェクトは共通なようなのでmockにする
const mock = sinon.mock(Bar.prototype);
mock.expects('constructor').once(); // ここでコケる
// ちなみにconstructor以外のメソッドは大丈夫(実行環境でObject.prototypeが持っているメソッド以外は大丈夫)
const foo = new Foo();
foo.fuga();
const isBarCalledWithNew = mock.verify();
assert(isBarCalledWithNew);
});
});
mock化して疑似constructorを作ろうとするとこける原因
実行環境によってはObject.prototypeの挙動が異なるようです
this.expectations = {};
chromeの場合オブジェクトリテラルによって作られたオブジェクトにはconstructorプロパティが存在することになります。
if (!this.expectations[method]) {
ですのでchromeだとここの分岐に入ってくれません。
エラーの場所
this.expectations[method]
がundefined
になり、下記行でエラーになります。
push(this.expectations[method], expectation);
phantomJSではこのエラーを吐かなかったのでおそらくObject.prototype
の仕様が異なるようです。
そもそもconstructor
プロパティってprototype
オブジェクトがコンストラクタ関数を参照するための属性なのでimportごとに文脈が異なるコンストラクタ関数を乗っ取れない以上無駄なアプローチだと思っています。(phantomJSではいけましたが)
解決策
importをのっとれば良い
依存関係にのっとったオブジェクトを注入してしまえば良いのです。
どうやって
injejct-loaderを使います
このnpmモジュールはimport文で読み込むもとのモジュールを置き換えることが可能です。
必ずしもsinon
と連携しなくても、非同期通信用モジュールを書き換えたりする用途で仕様可能です。
実際のコード
import assert from "power-assert";
import injector from "inject-loader!../src/foo";
import Bar from "../src/bar";
const barSpy = sinon.spy(Bar);
const Foo = injector({
// Foo内で使っているBarモジュールをモック化
"../src/bar": barSpy
}).default;
describe("Fooクラスのテスト", () => {
it("fugaメソッドの呼び出しでBarクラスがインスタンス化される", () => {
// spyできた!
const foo = new Foo();
foo.fuga();
const isBarCalledWithNew = barSpy.calledWithNew();
assert(isBarCalledWithNew); // => true
});
});
おわりに
sinonのissueでは'proxyquire'がおすすめされています。
どうしてもimport
構文を使いたいってわけでもなく、require
でモジュール読み込んでる場合はこれで良いと思います。