ユニットテストの実行環境を用意して Visual Studio Code のエクステンションを快適に TDD する

こんにちは [@ryu1kn](https://twitter.com/ryu1kn) です。これは [Visual Studio Code Advent Calendar 2016](http://qiita.com/advent-calendar/2016/vscode) の２日目の記事です。

Visual Studio Code (以下 VS Code) はバージョンが１に届いた頃からメインで使うようになりました。以降[いくつかエクステンション](https://marketplace.visualstudio.com/search?term=publisher%3A%22Ryuichi%20Inagaki%22&target=VSCode&sortBy=Relevance)を書いたのですが、今回はそれらのエクステンション開発時に必要だったユニットテストの環境準備について書きます。

## TL;DR

* エクステンション生成時についてくるテストの実行環境は VS Code の全 API が使えるインテグレーションテスト用のものであり、起動が遅く TDD でのユニットテストには向かない
* コマンドラインから手早くユニットテストを実行できるようにしておくと快適に TDD できる
* VS Code の API を提供する vscode モジュールは、エディタ上で実行する時とコマンドラインから実行する時とで違うものになる
* いつも依存性注入 (DI) をしていればユニットテストを書く上でこのことが問題になることはないが、そうしたくない（？）時は...

## プロジェクト生成時にはインテグレーションテストのサンプルがついてくる

[Testing Your Extension](https://code.visualstudio.com/docs/extensions/testing-extensions) にある通りですが、 Yeoman generator で Visual Studio Code のエクステンションの雛形を生成すると、サンプルのテストもついてきます。

プロジェクトの `.vscode/launch.json` には **Launch Tests** というエントリが予め用意されているので、デバッグビューを開いて **Launch Tests** を選択・実行すれば、エディタのウィンドウが新しく開かれ、その中でサンプルテストが実行されます。

サンプルテスト内にある以下のコメントの通り、 `vscode` をインポートすればエディタの API が使えるようになります。

```js
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
var vscode = require('vscode');
```

API を介して実際にエディタを操作しながらテストを行うことが可能で、 VS Code ではこうしたテストをインテグレーションテストと呼んでいます。インテグレーションテストは新しいエディタのインスタンスを起動した上でテストを実行するため、テスト開始までに時間がかかります。

一方、エクステンションを開発する際に書くユニットテストは実際にエディタを操作する必要がないため、エディタから独立してコマンドラインなどから実行することが可能です。テスト開始に時間がかかることもないので、テスト・実装・リファクタリングの TDD サイクルをリズムよく回すことができます。

## ユニットテストの実行環境を用意する

ここではユニットテストの実行に [mocha](https://mochajs.org/) を使います。特別な使い方はしていません。

`npm test` でテストを実行できるように `package.json` の `scripts` に `test` を登録しています。開発中は頻繁にテストを実行するので `watch` オプションをつけた `test-mode` というコマンドもついでに登録しています。

* package.json

    ```json
    {
      "scripts": {
        "test": "mocha --opts ./test/cli-test-mocha.opts",
        "test-mode": "mocha --opts ./test/cli-test-mocha.opts --watch",
        ...
      },
      ...
    }
    ```

`mocha` への設定は少し長くなることもあるので `opts` ファイルに分けています。

* test/cli-test-mocha.opts

    ```
    --recursive
    --ui tdd
    test
    ```

これでコマンドラインからテストが実行できるようになりました。

## vscode モジュールは Visual Studio Code 上で実行するかどうかで別物になる

Yeoman generator で生成したエクステンションの package.json には予め `vscode` が `devDependencies` の１つとして入っています。この `vscode` モジュールはインテグレーションテストの実行を助けるものであり、VS Code の API を含んではいません。つまり `require('vscode')` した時に得られるものは、 VS Code の上で実行する時とそれ以外（コマンドラインなど）から実行する時とで違います！

それでも、エディタの API を使うコンポーネントには `vscode` を DI するように実装していれば、このことがユニットテストで問題になることはありません。ユニットテストでコンポーネントに渡す `vscode` は `require('vscode')` で取得したものではなく、テストに応じて必要な分だけ定義したモックだからです。

それでも例えば次のように「 `vscode` の提供する定数（実際は Enum ）を参照しているのみだから」と DI していないコンポーネントでは問題になります。

```js
const OverviewRulerLane = require('vscode').OverviewRulerLane;

class Foo {

  // OverviewRulerLane を使う何らかのコード

}
```

このコードは Visual Studio Code の上で実行されれば何も問題ありませんが、ユニットテストから実行されると `require('vscode')` で例外が発生します。

この問題は例えば次のような方法で回避できるでしょう。

* （そもそもテスト可能にすることが DI の大きな利点なので）モジュールの定数の参照のみであっても DI する
* （そもそも `require('vscode')` が実行環境によって違うものになるのが問題なので）`devDependencies` にある `vscode` の代わりに自分で用意したモックを読み込ませる

２つ目のアプローチは nodejs のモジュール読み込みの仕組みに手を入れることになるので避けたい感いっぱいですが、補足しておきます。

テストで Foo をロードする時には `require` の代わりに [proxyquire](https://www.npmjs.com/package/proxyquire) のようなモジュールを使い、 `vscode` をモックオブジェクトに置き換えてしまう方法が取れます。

```js
const proxyquire = require('proxyquire');

const mockVscode = {
  OverviewRulerLane: {Center: 'OVERVIEW_RULER_LANE_CENTER'}
};

const Foo = proxyquire('../lib/foo', {vscode: mockVscode});

suite('Foo', () => {

  test('something', () => {
    ...
```

もしくは

* エディタ上で実行する時の定数を使ってテストしてみたい
* `proxyquire` のようなパッケージを使いたくない

のような事情があれば自分でモックを読み込むコードを用意できます。その場合はモックへの置き換えはテストファイルの外に出し、コマンドラインからの実行時のみ置き換えればいいでしょう。

* test/stub-vscode.js

    ```js
    // HACK: As vscode is not available if tests are not running on VSCode's extensionHostProcess,
    //       return mock vscode module when `require`d

    const mockVscode = {
      OverviewRulerLane: {Center: 2}
    };

    const moduleProto = Object.getPrototypeOf(module);

    moduleProto.require = new Proxy(moduleProto.require, {
      apply: (require, that, args) =>
        args[0] === 'vscode' ? mockVscode : Reflect.apply(require, that, args)
    });
    ```

* test/cli-test-mocha.opts

    ```
    --recursive
    --ui tdd
    --require test/stub-vscode.js
    test
    ```

上の例で stub-vscode.js で設定する `OverviewRulerLane.Center` の値は適当な値ではなく、エディタ実行時に `OverviewRulerLane.Center` が持つ値にする必要があります（そうでないと VS Code の上でユニットテストを実行した時にこけてしまいます）

## 最後に

このポストに書いたことは [TextMarker](https://github.com/ryu1kn/vscode-text-marker) というエクステンションを作った時に試してみたことなので、もしよければリポジトリを見てみて下さい。