📝

proxyquireでDI もっとモックテストしよう

2021/07/04に公開

こんにちは、@armorik83 です。みなさんテスト書いてますか。

今日は久々にテストの話を書きます。経緯を含めた流れで話を進めます。

ブラウザ向けライブラリを Node.js + Mocha でテストしたい

今、AngularJS 絡みのライブラリを開発中なんですが、そこでdocumentが出てくる点が問題となります。

sample.ts
// 開発中のソースを見本用にいじってます
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と同じことです。ブラウザ上で動かす際はwindowwindow.documentも値があるので問題ないですが、このソースを Mocha で(Karma などを使わず)テストするときに、windowが未定義として落ちます。

ReferenceError: document is not defined

ひとまずソース内に直に書かない

sample.ts
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);
  }
}
browser-dependencies.ts
'use strict';
export var document: Document = window.document;

まだ密結合ですが、間に DI の余地を設けました。

ReferenceError: window is not defined

windowが残っているので怒られます。

isomorphic っぽい分岐

UMD (Universal Module Definition)の実装にあるような分岐でエラーを黙らせます。後述のスタブ化の際、これをしていないとスタブ化も出来なかったため(同じエラーが出たため)必須なようです。

browser-dependencies.ts
'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があったとします。

test/unit/spec.js
import proxyquire from 'proxyquire';

const myLibrary = proxyquire('../../src/mylibrary', {
  './sub': {
    greet: 'hi!'
  }
});
src/mylibrary.js
import sub from './sub';
src/sub.js
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.es6mylibrary.tsをテストしようとしています。

tree
.
├── src
│   ├── browser-dependencies.ts
│   ├── mylibrary.ts
│   └── sub
│       └── sub.ts
└── test
    └── unit
        └── mylibrary-spec.es6
./src/mylibrary.ts
'use strict';
import sub from './sub/sub';

// 略
./src/sub/sub.ts
'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;
./src/browser-dependencies.ts
'use strict';
export var document: Document = (typeof window === 'object') ? window.document : (<Document>{});

documentを必要とする実装はsub.ts内だけです。テスト内のproxyquireは次のように書きます。

./test/unit/mylibrary-spec.es6
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ですよ。

./test/unit/mylibrary-spec.es6
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