型とテストのある暮らし〜jasmineの香りを添えて〜


## 概要

[TechFeed Live#2 「React vs Angular2」](http://techfeed.connpass.com/event/38582/)のLT資料です。

Angular2を見据えた上での、React/Reduxでのテストの書き方をまとめてみます。

---

## この資料で話すこと

- 型とテストのある暮らしについて
- TypeScriptを用いての開発ができる
- React/Reduxでの単体テストが書ける
  - 対象はReducer/ActionCreator/component全て
- karmaで単体テストできる
  - かつ、全体でなく個別にテストを回せる
- テストのカバレッジが取れる
  - もちろんTypeScriptコードで

---

## 型とテストのある暮らしについて

---

### デファクトの開発環境がほしい

フロント専業でないマンとしては、フロントの動向は追っててかなりしんどい。
デファクトの開発環境がほしい。

期待することとして

- 複数人でも開発できる
  - セマンティクスがわかりやすい
  - 型があるとドキュメントの役割があってなお良い
- IDEの補完サポートや静的チェックをしてほしい
  - 型があると良さそう
- 再利用しやすい設計になっている
  - テストが簡単に書けるはず
  - 疎結合になっているはず
- カバレッジ取りたい
  - 開発のKPIにしやすいので

があります。

---

### Angular2への期待と現状

なのでフレームワークとして「これに乗っかればサポートしてやるよ」的なものが欲しい！
Angular2にそれを期待しているのですが、現状は

- RCがRCじゃない
  - RC4から5の大幅なAPI変更
  - これまで書かれた記事ほとんど参考にならなくなった？
  - 安定版も安定でないのでは説が浮上、、
- 公式サンプルがSystem.js使ってる
  - フロント強いマン曰く、実際にはwebpackなどになるとのこと
  - 「え、じゃぁwebpackで書いてよ」
- テストのドキュメントがほぼない
  - フレームワークを名乗るなら頑張って欲しい
  - 現状、ボイラープレートが多そうな予感がしている。。

ツラみ。。。
なので仕方なくしばらくはReact使うつもりです。

---

### これからのフロントエンドに向けて

しかし今はまだアレでも、Angular2が採用しているスタックに乗っかっておくことで後々楽できそうな気がしています。
具体的には、

- Angular2ではTypeScript推奨
  - 型のある世界で生きてきたので型欲しい。
  - flowtypeよりTypeScript界隈の方が人気で型定義も充実してそう
  - WebStormもちゃんと対応してくれてて書きやすい
- Angular2ではテストランナーはkarmaっぽい
  - jestのようなnode/jsdom環境でのテストは不安
  - やっぱりブラウザ上でテストしたい
- Angular2ではテストフレームワークはjasmineっぽい
  - mochaでもいいけど、テストなら全部入りの方が考えることが少なそう

ということで、これらのスタックを使ってReact/reduxでテスト書いてます。

---

## TypeScriptを用いての開発ができる

---

Qiitaにまとめている、
[React + Redux + Typescriptシリーズ](http://qiita.com/uryyyyyyy/items/1f2022bbf914d65d635a)
をご覧ください。

---

## React/Reduxでの単体テストが書ける

---

全体のコードはこちら

https://github.com/uryyyyyyy/react-redux-sample/tree/test-karma

---

### テスト対象のソースコード

```
tree src/
src/
├── Counter.tsx
├── DispatchActions.ts
├── Index.tsx
├── Entities.ts
├── Reducer.ts
└── Store.ts
```

![myimage.gif](https://qiita-image-store.s3.amazonaws.com/0/7185/7a0fd345-7861-c2c3-5944-b0a909aa1e16.gif)

簡単なActionとhttp通信を含む例です。
（reduxのMiddleWareもrouterも使っていませんが、テストの書き方はほぼ同じになるはずです。）

ここでは、

- Index.tsx
  - reduxのstoreとcomponentの結びつけ
- Entities.ts
  - 共有の型定義を置き場
- Store.tsは
  - storeの生成

なのでテスト不要で、

- DispatchActions.ts
  - http通信を含むActionCreator層
- Reducer.ts
  - 純粋な関数になってるはずのReducer層
- Counter.tsx
  - view生成とイベント発火を担うComponent層

のテストを対象とします。

---

### karmaでのテストの設定

- karmaはfilesに指定したコードを全部テストする
- テストコード全部書くとバンドルとテスト実施でめちゃくちゃ時間かかる
- なので引数に与えたテストコードのみテストできるようにしたい


```package.json
"scripts": {
    ...
    "test:karma-ut": "karma start karma.conf.js",
    "test:karma-all": "karma start karma.conf.js **/*-test.tsx **/*-test.ts",
    ...
  },
```

```karma.conf.js
const args = process.argv;
args.splice(0, 4);

const polyfills = [
  'node_modules/jquery/dist/jquery.min.js'
];

//引数に与えたものと、polyfillやグローバルに読ませるものだけを含める。
const files = polyfills.concat(args);

module.exports = function(config) {
  config.set({

    basePath: '',

    frameworks: ['jasmine'],

    files: files,

    preprocessors: {
      '**/*-test.ts': ['webpack'],
      '**/*-test.tsx': ['webpack']
    },

    webpack: {
      resolve: {
        extensions: ['', '.ts', '.js', ".tsx"]
      },
      module: {
        loaders: [{
          test: /\.tsx?$/,
          loader: "ts-loader"
        }]
      }
    },

    reporters: ['mocha'],

    port: 9876,

    colors: true,

    logLevel: config.LOG_INFO,

    autoWatch: false,

    browsers: ['Chrome'],

    singleRun: true,

    concurrency: Infinity

};
```

使い方はこんな感じ

```
$ npm run test:karma-ut ./src/__test__/Reducer-test.ts 

> react-typescript-redux@1.0.0 test:karma-ut /~~~/react-redux-sample
> karma start karma.conf.js "./src/__test__/Reducer-test.ts"


START:
Hash: f4683f5fa2953dc3a97c
Version: webpack 1.13.2
Time: 7ms
webpack: bundle is now VALID.
webpack: bundle is now INVALID.
ts-loader: Using typescript@1.8.10 and /~~~/react-redux-sample/tsconfig.json
Hash: 394525184afc475de6c3
Version: webpack 1.13.2
Time: 957ms
                       Asset     Size  Chunks             Chunk Names
src/__test__/Reducer-test.ts  6.66 kB       0  [emitted]  src/__test__/Reducer-test.ts
chunk    {0} src/__test__/Reducer-test.ts (src/__test__/Reducer-test.ts) 4.91 kB [rendered]
    [0] ./src/__test__/Reducer-test.ts 1.15 kB {0} [built]
    [1] ./src/Reducer.ts 1.4 kB {0} [built]
    [2] ./src/Entities.ts 370 bytes {0} [built]
    [3] ./~/object-assign/index.js 1.99 kB {0} [built]
webpack: bundle is now VALID.
07 09 2016 07:20:02.423:INFO [karma]: Karma v1.2.0 server started at http://localhost:9876/
07 09 2016 07:20:02.424:INFO [launcher]: Launching browser Chrome with unlimited concurrency
07 09 2016 07:20:02.429:INFO [launcher]: Starting browser Chrome
07 09 2016 07:20:03.045:INFO [Chrome 53.0.2785 (Linux 0.0.0)]: Connected on socket /#ioetpDIDxOdeFnkTAAAA with id 36106878
  Reducer
    ✔ INCREMENT
    ✔ DECREMENT
    ✔ FETCH_SUCCESS

Finished in 0.009 secs / 0.001 secs

SUMMARY:
✔ 3 tests completed
```

---

### テストコード

#### Counter.tsx

テストしたいこと

- パラメータ通りにレンダリングできているか
- イベントを起こして、期待通りの発火ができているか

```ts:Counter-test.tsx
import * as React from "react";
import {Counter} from "../Counter";
import {GlobalState} from "../Entities";
import * as TestUtils from "react-addons-test-utils";
import * as ReactDOM from "react-dom";

describe('Counter', () => {

    it('rendering', () => {
        const actions:any = {};
        const state: GlobalState = {num: 1, loadingCount: 1};
        const counterComponent: any = TestUtils.renderIntoDocument(
            <Counter value={state} actions={actions} />
        );

        const counterDOM = ReactDOM.findDOMNode(counterComponent);
        const ps: NodeListOf<HTMLParagraphElement> = counterDOM.getElementsByTagName("p");

        const p0: HTMLParagraphElement = ps[0];
        expect(p0.textContent).toBe("loading");

        const p1: HTMLParagraphElement = ps[1];
        expect(p1.textContent).toBe("score: 1");
    });

    it('click', () => {
        const spy:any = {fetchAmount: null};
        spyOn(spy, 'fetchAmount');
        const state: GlobalState = {num: 0, loadingCount: 0};
        const counterComponent: any = TestUtils.renderIntoDocument(
            <Counter value={state} actions={spy} />
        );

        const counterDOM = ReactDOM.findDOMNode(counterComponent);
        const buttons: NodeListOf<HTMLButtonElement> = counterDOM.getElementsByTagName("button");
        const button: HTMLButtonElement = buttons[2];

        TestUtils.Simulate.click(button);
        expect(spy.fetchAmount).toHaveBeenCalledWith();
    });
});
```

#### DispatchActions.ts

テストしたいこと

- http通信・非同期の処理が正しく行えているか
- 正しくactionがdispatchされているか

```ts:DispatchActions-test.ts
import {ActionTypes} from "../Entities";
import {DispatchActions} from "../DispatchActions";
const request = require('superagent');
const mock = require('superagent-mocker')(request);

describe('DispatchActions', () => {


    beforeEach(() => {
        mock.clearRoutes()
    });

    it('increment',  () => {
        const spy:any = {dispatch: null};
        spyOn(spy, 'dispatch');
        const actions = new DispatchActions(spy.dispatch);
        actions.increment(100);
        expect(spy.dispatch).toHaveBeenCalledWith({ type: ActionTypes.INCREMENT, amount: 100});
    });

    it('fetchAmount success',  (done) => {

        mock.get('/api/count', () => ({body: {amount: 100}, status: 200}));

        const spy:any = {dispatch: null};
        spyOn(spy, 'dispatch');
        const actions = new DispatchActions(spy.dispatch);
        actions.fetchAmount().then(() => {
            expect(spy.dispatch.calls.argsFor(0)[0]).toEqual({ type: ActionTypes.FETCH_REQUEST });
            expect(spy.dispatch.calls.argsFor(1)[0]).toEqual({ type: ActionTypes.FETCH_SUCCESS, amount: 100 });
            done();
        });
    });

    it('fetchAmount fail',  (done) => {

        mock.get('/api/count', () => ({body: {}, status: 400}));

        const spy:any = {dispatch: null};
        spyOn(spy, 'dispatch');
        const actions = new DispatchActions(spy.dispatch);
        actions.fetchAmount().then(() => {
            expect(spy.dispatch.calls.argsFor(0)[0]).toEqual({ type: ActionTypes.FETCH_REQUEST });
            expect(spy.dispatch.calls.argsFor(1)[0]).toEqual({ type: ActionTypes.FETCH_FAIL });
            done();
        });
    });
});
```

#### Reducer.ts

テストしたいこと

- actionとstateを受け取って、正しいstateを返せているか

```ts:Reducer-test.ts
import {counter} from "../Reducer";
import {GlobalState, ActionTypes} from "../Entities";

describe('Reducer', () => {
    it('INCREMENT', () => {
        const state: GlobalState = {num: 4, loadingCount:0};
        const action = { type: ActionTypes.INCREMENT, amount: 3};
        const result = counter(state, action);
        expect(result.num).toBe(state.num + 3);
        expect(result.loadingCount).toBe(state.loadingCount);
    });

    it('DECREMENT', () => {
        const state: GlobalState = {num: -2, loadingCount:0};
        const action = { type: ActionTypes.DECREMENT, amount: 10};
        const result = counter(state, action);
        expect(result.num).toBe(state.num - 10);
        expect(result.loadingCount).toBe(state.loadingCount);
    });

    it('FETCH_SUCCESS', () => {
        const state: GlobalState = {num: -2, loadingCount:1};
        const action = { type: ActionTypes.FETCH_SUCCESS, amount: 10};
        const result = counter(state, action);
        expect(result.num).toBe(state.num + 10);
        expect(result.loadingCount).toBe(state.loadingCount - 1);
    });
});
```
---

## テストのカバレッジが取れる

---

カバレッジも取ります。弊社では主にcoverallsを使っているのでそれを例に挙げます。
流れとしては、

- TypeScriptをjsにトランスパイルする
  - ソース・テストコード共に行う
  - ソースマップを吐いておく
- jsのテストを流してカバレッジを取る
- 取ったカバレッジとソースマップに合うようにremapする
- 任意の形式で出力する
  - 弊社ではscalaコードでもカバレッジを取るので、そちらの結果とのマージもしている


```package.json
"scripts": {
    ...
    "test:karma-coverage": "tsc && karma start karma.conf.js ./dist/**/*-test.js",
    "coverage-html": "remap-istanbul -i ./coverage/XXX browser/coverage-final.json -o coverage-report -t html",
    "coveralls": "remap-istanbul -i coverage/XXX browser/coverage-final.json -o coverage/coverage-final.json && istanbul report lcov && node mergeCoveralls.es6 < ./coverage/lcov.info"
    ...
  },
```

```karma.conf.js
const args = process.argv;
args.splice(0, 4);

const polyfils = [
  'node_modules/jquery/dist/jquery.min.js'
];

//引数に与えたものと、polyfillやグローバルに読ませるものだけを含める。
var files = polyfils.concat(args);

module.exports = function(config) {
  config.set({

    //...

    preprocessors: {
      '**/*-test.js': ['webpack']
    },

    webpack: {
      resolve: {
        extensions: ['', '.ts', '.js', ".tsx"]
      },
      module: {
       //...
        postLoaders: [{
          test: /\.js/,
          exclude: /(__test__|node_modules|bower_components)/,
          loader: 'istanbul-instrumenter'
        }]
      }
    },

    reporters: ['mocha', 'coverage'],

    coverageReporter: {
      type : 'json',
      dir : 'coverage/'
    }

};
```

使い方

```
# テスト実施＆カバレッジ取得
npm run test:karma-coverage

# htmlでカバレッジを見たい場合（主にローカル）
npm run coverage-html

# coverallsに送信する場合（主にCI）
npm run coveralls
```

---

ちなみに、、、

jsdom/mocha版もあります。（以前はこちらを使っていました。）

https://github.com/uryyyyyyy/react-redux-sample/tree/test-jsdom

---

## まとめ

React/Reduxだと、疎結合に組めてテストも簡単なのでオススメです。
dispatchのところはどうしても型の制約をつけにくくなりますが、まぁ仕方ないですね。

（ただ、React界隈はデファクトもなく公式もflowtype推しなので、情報は分散していてツラみ。）

Angular2は早く安定版出してドキュメント充実させてほしい！