[ES2015]importされたモジュールのインスタンス化をsinonを用いて乗っ取ることができない時はinject-loaderで解決


はじめに

以下の状況下で困っている方に向けた記事です

  • es2015でbabelでトランスパイルすることを前提にテストコードを書いている
  • テスト対象のモジュール[Foo]が別のモジュール[Bar]をimportしていて、[Bar]のインスタンス化をテストしたい

困っていたこと

※karmaでmocha, sinonを用いていることを前提としています

  • exportされたクラスがimportされた場所毎に異なるコンストラクタ関数オブジェクトになっている
    • そのせいでテスト対象のモジュールがimportしているコンストラクタ関数はspyか不可能
  • 異なるコンテキストのコンストラクタ関数でもprototypeオブジェクトは共通らしいので、prototypeオブジェクトをmock化してconstructorメソッドをのっとればいいじゃん
    • エンジンによってそれで良いパターンとだめなパターンがある
      • 例) phantomJSではその方法でいけるが、chromeでアクセスするとだめ
      • そもそもes2015のconstructorって・・・

実際にコードでお見せします

module[Foo(../src/foo)]
import Bar from "../src/bar";

export default class Foo {
  constructor() {
    this.hoge = 'hoge';
  }

  fuga() {
    const bar = new Bar();
    bar.piyo();
  }
}
module[Bar(../src/bar)]
export default class Bar {
  constructor() {
    this.fuga = 'fuga';
  }

  piyo() {
    alert('piyo');
  }
}
spec[Foo]
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の挙動が異なるようです

mock.js#L63
this.expectations = {};

chromeの場合オブジェクトリテラルによって作られたオブジェクトにはconstructorプロパティが存在することになります。

mock.js#L68
if (!this.expectations[method]) {

ですのでchromeだとここの分岐に入ってくれません。

エラーの場所

this.expectations[method]undefinedになり、下記行でエラーになります。

mock.js#L81
push(this.expectations[method], expectation);

phantomJSではこのエラーを吐かなかったのでおそらくObject.prototypeの仕様が異なるようです。

そもそもconstructorプロパティってprototypeオブジェクトがコンストラクタ関数を参照するための属性なのでimportごとに文脈が異なるコンストラクタ関数を乗っ取れない以上無駄なアプローチだと思っています。(phantomJSではいけましたが)

解決策

importをのっとれば良い

依存関係にのっとったオブジェクトを注入してしまえば良いのです。

どうやって

injejct-loaderを使います

このnpmモジュールはimport文で読み込むもとのモジュールを置き換えることが可能です。

必ずしもsinonと連携しなくても、非同期通信用モジュールを書き換えたりする用途で仕様可能です。

実際のコード

spec[Foo]
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でモジュール読み込んでる場合はこれで良いと思います。