イマドキのJSテスト - テスト環境をモダンフロントエンドツール群で整備する編 〜 JSおくのほそ道 #032

こんにちは、ほそ道です。

今回はビルドツール・モジュールローダ・altJS(JSコンパイラ)を組み合わせてさらにテストコードのおくのほそ道に入り込んでいきます。
今回Gulp + Webpack + Karma + Jasmine + ES6を使用しますが、
GruntだったりBrowserifyだったりMochaだったりCoffeescriptだったりに置き換えても基本的な考え方は近しい感じになるかと思います。
あとはここにSPAフレームワークとかを組み合わせればいよいよモダンなテストコードになっていきますね。

[目次はこちら](http://qiita.com/setzz/items/ad9a9c99a4fda041a563)

# 今回解決すること・設計方針
- Webpackとテストコードを組み合わせる。なんかバンドルしたりごにょごにょやってるどこにテストを挟むのか？を解決する。
- ソースコードもテストコードもES6で書く。babelないしはaltJSのコンパイルごにょごにょやってるどこにテストを挟むのか？を解決する。
- Gulpも使ってるんだけど誰に何の仕事をさせてどういう順番で動かせばテストになるのか・・・を解決する

> **今回の解決方法としてはアプリケーションビルドとは別枠でテストを走らせます**
> **それぞれのテストモジュールごとにloader付きのwebpackビルドが走りテストを行わせます**
> **GulpはWebpackのキックだけでビルド周りの情報は薄めさせる。もっと大きな前処理/後処理(ファイル構成いじったり)とかはGulpに任せる。**

では、やっていきましょう。

# まずは被テストコードを用意

まずはサクッと動くコードを用意しましょう。
JSコードはモジュールテストの回で扱ったネコサービスをES6に書き直します。
ここの内容が良く解らなければ[webpack入門](http://qiita.com/setzz/items/9e00071dacc61d368a5e)を併せてお読みください。
ネコサービスとテストの初期実装は[こちら](http://qiita.com/setzz/items/4e508e804b0e50e7b6ec)です。
ES6についてはまとめてませんが。。

プロジェクト構成は下記となります。

```text:プロジェクト構成
├── node_modules
├── src
│   ├── Cat.js
│   ├── CatService.js
│   ├── Food.js
│   └── entry.js
├── package.json
└── webpack.conf.js
```

まずはwebpackとloaderのインストールから

```bash:コンソール
npm i -D webpack babel-loader 
```

続いて`webpack.conf.js`です。

```js:webpack.conf.js
module.exports = {
  entry: { app: ["./src/entry.js"] },
  output: { filename: "build/[name].js" },
  devtool: "#source-map",
  module: { loaders: [ {test: /\.js$/, loader: "babel"} ] }
};
```

では被テストコードを用意します。

```js:src/Food.js
export default class Food {

  constructor(name) {
    this.name = name;
    this.weight = 0;
    switch(name) {
      case 'chikuwa':
        this.weight = 1;
        break;
      case 'fish':
        this.weight = 2;
        break;
      case 'beef':
        this.weight = 3;
    }
  }
}
```

```js:src/Cat.js
export default class Cat {
  constructor(name) {
    this.name = name ? name : 'Tama';
    this.weight = 5;
  }

  eat(food) {
    this.weight += food.weight;
  }
}
```

```js:src/CatService.js
import Cat from './Cat'
import Food from './Food'

export default class catService {
  constructor() {
    this.cats = {}
  }

  // お泊まりサービスをやっています
  checkin(cat) {
    this.cats[cat.name] = cat;
  }

  checkout(name) {
    const cat = this.cats[name];
    delete this.cats[name];
    return cat;
  }

  // 依頼された猫に指定の餌をあげるサービスです
  feed(name, foodName) {
    this.cats[name].eat(new Food(foodName));
  }

  // 猫を産んでお客様に差し上げるサービスです
  newCat(name) {
    return new Cat(name);
  }
}
```

これでコードが揃いました。
webpackビルドすればちゃんと動くことが確認できると思います。

```bash:コンソール
 webpack --config webpack.conf.js    
```

# さあテスト！

上で作成したコード群をテストしていきましょう。
ここでの内容が良く解らない方は
[karmaを使ったUIテスト](http://qiita.com/setzz/items/311e3eac4f55183b62c2)を併せてお読みいただければ幸いでございます。

最終的なプロジェクト構成は下記となります。

```text:プロジェクト構成
├── node_modules
├── src
│   ├── Cat.js
│   ├── CatService.js
│   ├── Food.js
│   └── entry.js
├── spec
│   ├── CatServiceSpec.js
│   ├── CatSpec.js
│   └── FoodSpec.js
├── package.json
├── karma.conf.js
└── webpack.conf.js
```

## パッケージインストール

パッケージを追加インストールします。

```bash:コンソール
# karmaの回で取り上げたパッケージ
npm i -D karma karma-jasmine karma-chrome-launcher karma-firefox-launcher karma-spec-reporter 

# 今回初登場のパッケージ
npm i -D karma-webpack babel-plugin-rewire babel-runtime
```

今回、キモとなるパッケージは初登場のkarma-webpackです。これがkarmaに独自に行うwebpack設定を有効にし、karma startコマンドにwebpack連動させる役割を果たします。

## karma.conf.js

では一番大事なkarma.conf.jsです。

```karma.conf.js
module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    files: ['spec/*.js'],
    exclude: [],
    preprocessors: {'spec/*.js': ['webpack']},
    reporters: ['spec'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome', 'Firefox'],
    singleRun: false,
    webpack: {
      module: { loaders: [
        {test: /\.js$/, exclude: /node_modules/, loader: 'babel?plugins=rewire&optional=runtime'}
      ]}
    },
    plugins: [
      'karma-webpack', 'karma-jasmine',
      'karma-chrome-launcher', 'karma-firefox-launcher', 'karma-spec-reporter'
    ]
  });
};
```

- 新しく出てきた要素のピックアップ

| 要素 | やってること |
| --- | --- |
| `files ['spec/*.js']` | karma回ではsrcも呼び出して相互参照できるようにしていましたが今回はspecのみとします。|
| `preprocessors {'spec/*.js': ['webpack']}` | テストディレクトリspecのそれぞれのファイルに対してwebpackをカマします。|
| `webpack: {...}` | ルートに作ったwebpack.conf.jsとは別にテストのためのwebpack設定を記述します。entryやoutput要素は入れません。|
| `webpack.module.loaders:[...]` | ローダー設定を記述しますが今回注目は`babel?plugins=rewire&optional=runtime`の部分です。これはbabel・webpack環境で使用できるrewireを使えるようにコンパイルに組み込んでいます。今回環境ではJasmine回で紹介したrewireは使えません。|
| `plugins:[...]` | karmaで使用するプラグインを指定します。karma-webpackを使う場合はこの指定が必要になります。 |

## テストコード

Food.jsとCat.jsは特筆すべき点ないと思います。

```js:spec/Food.js
import Food from '../src/Food.js'

describe('Food constructor', () => {
  it('ちくわのweightは1であるべし', () => {
    expect(new Food('chikuwa').weight).toBe(1);
  });
  it('魚のweightは2であるべし', () => {
    expect(new Food('fish').weight).toBe(2);
  });
  it('ビーフのweightは3であるべし', () => {
    expect(new Food('beef').weight).toBe(3);
  });
  it('未設定なFoodのweightは0であるべし', () => {
    expect(new Food('Daikon').weight).toBe(0);
  });
});
```

```js:spec/Cat.js
import Cat from '../src/Cat.js'

describe('Cat', function () {
  describe('.constructor()', () => {

    it('デフォルト名前はTamaになるべし', () => {
      expect(new Cat().name).toBe('Tama');
    });

    it('名前を指定すればその名が設定されるべし', () => {
      expect(new Cat('Mike').name).toBe('Mike');
    });

    it('最初の体重は5となるべし', () => {
      expect(new Cat().weight).toBe(5);
    });
  });

  describe('.eat()', function() {
    it('食べ物のweighだけネコの体重が増加すべし', () => {
      var c1 = new Cat();
      var c2 = new Cat();
      c1.eat({weight: 8});
      expect(c1.weight - c2.weight).toBe(8);
    });
  });
});
```

CatServiceはrewire周りを下記のように変更しています。これはwebpack・babelに対応できるようにするため`babel-plugin-rewire`パッケージを使うようにしたためです。
- `require(rewire)`的なことをしない。
- モックを設定する`__set__`メソッドを`__Rewire__`メソッドに変更する。

```js:spec/
import CatService from '../src/CatService.js'

describe('CatService', () => {
  let service = new CatService();

  // ネコモック
  class Cat {
    constructor(name) {
      this.name = name;
      this.weight = 5;
      this.eat = food => {}
    }
  }

  // 食い物モック
  class Food {
    constructor(weight) {
      this.weight = weight;
    }
  }

  // モック注入！
  CatService.__Rewire__('Cat', Cat);
  CatService.__Rewire__('Food', Food);

  describe('.checkin()', () => {
    beforeEach(() => {
      // 検査ごとにserviceを初期化
      service = new CatService();
    });

    it('別名のネコを2匹泊めたらcatsには２匹いるべし', () => {
      service.checkin(new Cat('Tama'));
      service.checkin(new Cat('Mike'));
      expect(Object.keys(service.cats).length).toBe(2);
    });

    it('同名のネコのお泊まりは残念ながら受け付けぬべし', () => {
      service.checkin(new Cat('Mike'));
      service.checkin(new Cat('Mike'));
      expect(Object.keys(service.cats).length).toBe(1);
    });
  });

  describe('.checkout()', () => {
    // あえてbeforeEachせずに直上の状態を引き継いでみる

    it('指定したネコを返却すべし', () => {
      let returnedCat;
      service.checkin(new Cat('Tama'));
      returnedCat = service.checkout('Tama');
      // Mikeだけが残ってるはず
      expect(Object.keys(service.cats).length).toBe(1);
      expect(Object.keys(service.cats)[0]).toBe('Mike');
      // Tamaが返却されるはず
      expect(returnedCat.name).toBe('Tama');
    })
  });

  describe('.feed()', () => {
    beforeEach(() => {
      service = new CatService();
    });

    it('Cat.eatメソッドが呼ばれるべし', () => {
      const mike = new Cat('Mike');
      spyOn(mike, 'eat');
      service.checkin(mike);
      service.feed('Mike', new Food(3));
      expect(mike.eat).toHaveBeenCalled();
    });
  });

  describe('.newCat()', () => {
    it('新しいネコを生成すべし', () => {
      expect(service.newCat('Mike') instanceof Cat).toBeTruthy();
    });
  });
});
```

## テスト実行

最後にpackage.jsonにテスト実行コードを登録しましょう

```json:package.json
{
  …
  "scripts": {
    "test": "karma start karma.conf.js"
  },
  …
}
```

さあ、テスト実行です。

```bash:コンソール
npm run
```

---

※うまく動かない時

babel-plugin-rewireとbabel-loaderはそれぞれがbabel-coreを参照しておりコンフリクトを起こす場合があります。
ほそ道の環境では下記を叩いて一括で入れ直したらうまく動くようになりました。

```bash:コンソール
npm i -D babel-plugin-rewire babel-loader babel-core
```

---

- 実行結果  
![スクリーンショット 2015-05-31 23.41.55.png](https://qiita-image-store.s3.amazonaws.com/0/39330/6fa6cb2c-5917-2be9-801e-b66900326552.png)  
![スクリーンショット 2015-05-31 23.42.15.png](https://qiita-image-store.s3.amazonaws.com/0/39330/67ee3b2f-bd77-9df0-adc0-9fbd4262f768.png)


コンソールの内容から**Spec.jsごとにwebpackがモジュールを取り込みビルドしていることがわかります。

# Gulpから実行する

最後にGulpも絡めておきましょう。やる事はwebpackビルドとkarmaテストの実行です。
とは言っても、ここまででベースはできているので叩くところだけ任せれば良いです。
以前のビルドのクリアなどの前処理、ミニファイなどの後処理はこっちでやったほうがいいと思います。
ではさらっとインストールから。

```bash:コンソール
npm i -D gulp gulp-webpack-build
```

ルートにgulpfileを作りましょう。
今回はソリッドな構成にしましたし、こいつに固有の設定を色々持たせないほうがシンプルでいられると思います。

```js:gulpfile.js
var gulp = require('gulp'),
    webpack = require('gulp-webpack-build'),
    karma = require('karma').server,
    WEBPACK_CONFIG = __dirname + '/webpack.conf.js',
    KARMA_CONFIG = __dirname + '/karma.conf.js';

gulp.task('webpack-build', function () {
  return gulp.src(WEBPACK_CONFIG)
      .pipe(webpack.run())
      .pipe(gulp.dest(''));
});

gulp.task('karma-test', function () {
  karma.start({configFile: KARMA_CONFIG});
});
```

実行コマンドは以下です。

```bash:コンソール
# ビルド実行
gulp webpack-build

# テスト実行
gulp karma-test
```

# まとめ

今回新しく登場したパッケージのまとめです。

|パッケージ名| 概要 |
| --- | --- |
| karma-webpack | karmaの実行に併せてテストコードに必要なモジュールのバンドル化とコードコンパイルをwebpackを使って実行できる | 
| babel-plugin-rewire | babelコンパイル環境下でrewireを使えるようにする |
| babel-runtime | ES6構文のPolyfillのために入れました。今回の場合はbabel-plugin-rewireでObject.assignメソッドを使えるようにするため |
| gulp-webpack-build | gulp経由からwebpackを実行します。 |

さて、今回でだいぶ実践的な環境に近づいてきました。テストコードの世界、奥深いですねぇ。
またSPAフレームワークや遷移が絡むテストについても随時ピックアップしていきたいと思います。

今回は以上です。