ava で始めるテスト自動化入門 実践編

先日Mocha でテストの自動化記事を書きましたが、@akameco 氏の記事でava を取り上げていましたので、今度はava を使って同じプログラムをテストしてみることにします。
多々コード等を参考にしている部分がありますので、@akameco 氏にはこの場を借りてお礼を申し上げます。ありがとうございます<(_ _)>

# ava で始めるnode のテスト自動化 
ava の特徴としては主に以下のようなものがあります(他にもあります)。
* 各ファイルが別プロセスとしてtest される(他のtest ケースの環境変更が他のtest ケースに影響を及ぼさない)
* 各テストケースが並行で実行される
* Ecma Script 6 対応

# 準備 
```text:ディレクトリ構成
nodeprj/                // <- ワークディレクトリ
  index.js              // <- テスト対象のプログラム
  +--test/
    +--test-divided.js  // <- divided.js をテストするプログラム
```


予めディレクトリを作成しておきます。


```text:ディレクトリの作成
~$ mkdir -p nodeprj/test
~$ cd nodeprj
```


npm init を使用してプロジェクトを初期化します。
入力する内容例としては下記のとおりです。


```text
~$ npm init
...
name: (nodeprj) 
version: (1.0.0) 
description: The program for dividing numbers.
entry point: (index.js) 
test command: ava -v
git repository: 
keywords: divided
author: Tsutomu Nakamura
license: (ISC) MIT
...
```


# ava の導入 
ava に関するパッケージをインストールします。
またava の`--init` オプションで`package.json` ファイルを初期化します。
また、`--save-dev` オプションを使用し、package.json のdevDependencies に追記していきます。


```text:インストール
~$ npm install -g ava
~$ ava --version
0.14.0
~$ ava --init
......
~$ cat package.json
{
  "name": "nodeprj",
  ......
  "scripts": {
    "test": "ava -v"
  },
  ......
  "devDependencies": {
    "ava": "^0.14.0"
  }
}
```


後ほどテストで使用するsinon もインストールしておきます。
インストールするときは`npm` コマンドのオプションに`--save-dev` オプションを使用して、`package.json` ファイルに依存関係として追記されるようになります。


```text:npminstall--save-devsinon
~$ npm install --save-dev sinon
......
~$ cat package.json
  ......
  "devDependencies": {
    "ava": "^0.14.0",
    "sinon": "^1.17.3"
  }
}
```



## テストの概要 
本セクションでは、次のようなテスト方法について、実際の簡単なプログラムをテストすることで説明していきます。

* 同期的な処理のテスト(基本的なテスト)
* 例外がスローされるかどうかの処理のテスト
* 非同期な処理のテスト
* hook を使ったテストコードのリファクタリング


## 同期的な処理のテスト(基本的なテスト) 
`test/test-divided.js` ファイルを新規に作成し、以下のプログラムを作成します。


```javascript:test/test-divided.js
import test from 'ava';
import divided from '../index.js';

test('divided#calculate returns 2 when the value is 4', t => {
    t.is(divided.calculate(4), 2);
});
```


それではテストを実行してみましょう。
ava は引数にファイルを指定しない場合、デフォルトで`test.js`, `test-*.js`, `test/**/*.js` な条件にマッチするファイルを検索してテストコードとして実行してくれるので、今回の場合は`test/test-divided.js` が呼びだされます。


```text:npmava
~$ npm test

> nodeprj@1.0.0 test /path/to/PracticeMyNodeUnit/ava/nodeprj
> ava -v


module.js:338
    throw err;
          ^
Error: Cannot find module '../index.js'
    at Function.Module._resolveFilename (module.js:336:15)
    ......
  ✖ test/test-divided.js exited with a non-zero exit code: 1

  0 tests passed
  1 uncaught exception

npm ERR! Test failed.  See above for more details.
```


すると上記のように`index.js` が見つからないため、テストがエラーとなって終了しました。
それでは`index.js` を作成し、`calculate` メソッドを実装し、再度テストを実行してみましょう。


```javascript:index.js
'use strict'

exports.calculate = function(num) {
    return num / 2;
};
```



```text:npmtest
~$ npm test

> nodeprj@1.0.0 test /path/to/PracticeMyNodeUnit/ava/nodeprj
> ava -v

  ✔ divided#calculate returns 2 when the value is 4

  1 test passed
```


テストをパスしました。
それでは次に、数値が割り切れなかった場合に小数点以下が切り捨てられるかどうかを確認するテストケースを追加し、テストしてみましょう。


```javascript:test/test-divided.js
import test from 'ava';
import divided from '../index.js';

test('divided#calculate returns 2 when the value is 4', t => {
    t.is(divided.calculate(4), 2);
});

test('divided#calculate returns 1 when the value is 3', t => {
    t.is(divided.calculate(3), 1);
});
```



```text:npmtest
~$ npm test
```

![NodeUnitAva_0000.png](https://qiita-image-store.s3.amazonaws.com/0/70152/96439432-bb4a-1df6-3ed9-a89fee4e8962.png)


想定している結果が1 であるのに1.5 が返ってきているためエラーとなっています。
エラーの内容に従ってプログラムの内容を修正し、再テストします。


```javascript:index.js
'use strict'

exports.calculate = function(num) {
    return Math.floor(num / 2);
};
```



```text:npmtest
~$ npm test
```

![NodeUnitAva_0001.png](https://qiita-image-store.s3.amazonaws.com/0/70152/b18a058b-8c83-7409-cd25-25358d003b6f.png)


## -w, --watch オプションでファイルの変更が発生したことを契機にテストを自動実行する 
ava のテストコマンドに`-w, --watch` オプションをつけると、テストコマンドが待ち受け状態に入り、テストコードやテスト対象ファイルに変更が発生したタイミングで自動的にテストを実行してくれるようになります。
また、`npm test` コマンドでは`package.json` ファイルに記載されたテストコマンドとオプションで実行するようになっていますが、そのコマンドに追加でオプションを指定したい場合は`npm test -- <追加オプション>` という形式で指定します。


```text:--watchモードでavaのテストを実行する
~$ npm test -- --watch

  ✔ divided › divided#calculate returns 2 when the value is 4
  ✔ divided › divided#calculate returns 1 when the value is 3

  2 tests passed

## 変更が発生すれば自動的にテストを再実行
```


## テストの関数の記述スタイルについて 
ava はES6 なスタイルで記述することができるので、先ほど書いたテストケースは次のように書くこともできます。


```javascript:functionを使った書き方
test('divided#calculate returns 2 when the value is 4', function(t) {
    t.is(divided.calculate(4), 2);
});
```


また、function 名を明示的に定義するようにすれば、そのfunction 名が自動的にテストケースのタイトルとして走るようになっています。


```javascript:function名をタイトルとして使用する
test(function divided#calculate_returns_2_when_the_value_is_4(t) {
    t.is(divided.calculate(4), 2);
});
```


また、テストケースのタイトルを省略することも可能です。


```javascript:タイトルを省略する
test(function (t) {
    t.is(divided.calculate(4), 2);
});

test(t => {
    t.is(divided.calculate(4), 2);
});
```


# 例外を判定するテストを作成する 
プログラムに数値以外の値が引数に渡された時に例外を投げることを確認するテストケースを作成します。
例外が投げられることを確認するには`throws` function を使用します。


```javascript:test/test-divided.js
import test from 'ava';
import divided from '../index.js';

test('divided#calculate returns 2 when the value is 4', t => {
    t.is(divided.calculate(4), 2);
});

test('divided#calculate returns 1 when the value is 3', t => {
    t.is(divided.calculate(3), 1);
});

test('divided#calculate throws exceptions when the value is other than numbers', t => {
    t.throws(divided.calculate);
    t.throws(() => divided.calculate(null), 'Type of numeric is expected.');
    t.throws(() => divided.calculate('abc'), / numeric /);
    t.throws(() => divided.calculate([]), TypeError, /^Type of numeric /);
});
```



```text:npmtest
$ npm test
  ......
  ✔ divided#calculate returns 2 when the value is 4
  ✔ divided#calculate returns 1 when the value is 3
  ✖ divided#calculate throws exceptions when the value is other than numbers Missing expected exception..

  1 test failed

  1. divided#calculate throws exceptions when the value is other than numbers
  AssertionError: Missing expected exception..
  ......

npm ERR! Test failed.  See above for more details.
```


例外が投げられていないため、テストに失敗しました。
プログラムに例外をスローするテストケースを追加し、再テストします。


```javascript:index.js
'use strict'

exports.calculate = function(num) {
    if (typeof num !== 'number' || isNaN(num)) {
        throw new TypeError('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};
```



```text:npmtest
$ npm test
  ......
  ✔ divided#calculate returns 2 when the value is 4
  ✔ divided#calculate returns 1 when the value is 3
  ✔ divided#calculate throws exceptions when the value is other than numbers

  3 tests passed

```


# 非同期処理のテストをする 
非同期で標準入力から値を読み込み、その値の計算結果を出力するプログラムのテストを行います。
EventEmitter, sinon を使用してイベントを非同期に呼び込む処理をエミュレートし、テストを行います。


```javascript:test/test-divided.js
import test from 'ava';
import sinon from 'sinon';
import {EventEmitter} from 'events';
import divided from '../index.js';

test('divided#calculate returns 2 when the value is 4', t => {
    t.is(divided.calculate(4), 2);
});

test('divided#calculate returns 1 when the value is 3', t => {
    t.is(divided.calculate(3), 1);
});

test('divided#calculate throws exceptions when the value is other than numbers', t => {
    t.throws(divided.calculate);
    t.throws(() => divided.calculate(null), 'Type of numeric is expected.');
    t.throws(() => divided.calculate('abc'), / numeric /);
    t.throws(() => divided.calculate([]), TypeError, /^Type of numeric /);
});

test('divided#read prints "result: 4" when the value is 8 that given from the stdin', t => {
    var ev = new EventEmitter();
    var _console_log = console.log;

    sinon.stub(console, 'log');
    process.openStdin = sinon.stub().returns(ev);
    divided.read();
    ev.emit('data', '8');
    t.true(console.log.calledOnce);
    t.true(console.log.calledWith('result: 4'));

    console.log = _console_log;
});
```



```text:avaは特に指定なくテストケースを記載した場合、それは
~$ npm test
  ......
  ✔ divided#calculate returns 2 when the value is 4
  ✔ divided#calculate returns 1 when the value is 3
  ✔ divided#calculate throws exceptions when the value is other than numbers
  ✖ divided#read prints "result: 4" when the value is 8 that given from the stdin failed with "undefined is not a function"

  1 test failed

  1. divided#read prints "result: 4" when the value is 8 that given from the stdin
  TypeError: undefined is not a function
  ......

npm ERR! Test failed.  See above for more details.
```


`read` function が未定義のためテストに失敗しました。
`read` を追加し、再テストしてみましょう。


```javascript:index.js
'use strict'

exports.calculate = function(num) {
    if (typeof num !== 'number' || isNaN(num)) {
        throw new TypeError('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};

exports.read = function() {
    var stdin = process.openStdin();
    stdin.on('data', function(chunk) {
        var param = Number(chunk);
        var result = exports.calculate(param);
        console.log('result: ' + result);
    });
};
```



```text:npmtest
$ npm test
  ......
  ✔ divided#calculate returns 2 when the value is 4
  ✔ divided#calculate returns 1 when the value is 3
  ✔ divided#calculate throws exceptions when the value is other than numbers
  ✔ divided#read prints "result: 4" when the value is 8 that given from the stdin

  4 tests passed
```



# 例外を扱う非同期処理をテストする 
非同期で標準入力から値を読み込み、その値の計算結果を出力するfunction に数値以外の値が渡された時に例外が投げられることを確認する処理を追加します。
今回は例外がスローされて`'Type of numeric is expected.'` が表示されることを想定しています。


```javascript:test/test-divided.js
import test from 'ava';
import sinon from 'sinon';
import {EventEmitter} from 'events';
import divided from '../index.js';

test('divided#calculate returns 2 when the value is 4', t => {
    t.is(divided.calculate(4), 2);
});

test('divided#calculate returns 1 when the value is 3', t => {
    t.is(divided.calculate(3), 1);
});

test('divided#calculate throws exceptions when the value is other than numbers', t => {
    t.throws(divided.calculate);
    t.throws(() => divided.calculate(null), 'Type of numeric is expected.');
    t.throws(() => divided.calculate('abc'), / numeric /);
    t.throws(() => divided.calculate([]), TypeError, /^Type of numeric /);
});

test.serial('divided#read prints "result: 4" when the value is 8 that given from the stdin', t => {
    var ev = new EventEmitter();
    var _console_log = console.log;

    sinon.stub(console, 'log');
    process.openStdin = sinon.stub().returns(ev);
    divided.read();
    ev.emit('data', '8');
    t.true(console.log.calledOnce);
    t.true(console.log.calledWith('result: 4'));

    console.log = _console_log;
});

test.serial('divided#read prints "Type of numeric is expected." when the value is not a numeric', t => {
    var ev = new EventEmitter();
    var _console_log = console.log;

    sinon.stub(console, 'log');
    process.openStdin = sinon.stub().returns(ev);
    divided.read();
    ev.emit('data', 'abc');
    t.true(console.log.calledOnce);
    t.true(console.log.calledWithMatch('Type of numeric is expected.'));

    console.log = _console_log;
});
```


今回、標準入力から値を受け取って結果を表示するテストケースでは`console.log` をstub として定義し、他のテストケースに影響が内容に元の状態に戻す必要があるため、`test.serial` を使用するようにしています。


```text:npmtest
~$ npm test
  ......
  ✔ divided#read should print "result: 4" when the value is 8 that given from the stdin
  ✖ print "Type of numeric is expected." when the value is not a numeric failed with "Type of numeric is expected."
  ✔ divided#calculate should return 2 when the value is 4
  ✔ divided#calculate should return 1 when the value is 3
  ✔ divided#calculate should throw exceptions when the value is other than numbers

  1 test failed

  1. print "Type of numeric is expected." when the value is not a numeric
  TypeError: Type of numeric is expected.
  ......

npm ERR! Test failed.  See above for more details.
```


例外がスローされなかったためテストに失敗しています。
標準入力から値を受け取って結果を表示する処理に、数値以外の値が渡された時に例外をスローする処理を追加し、再テストしてみましょう。


```javascript:index.js
'use strict'

exports.calculate = function(num) {
    if (typeof num !== 'number' || isNaN(num)) {
        throw new TypeError('Type of numeric is expected.');
    }
    return Math.floor(num / 2);
};

exports.read = function() {
    var stdin = process.openStdin();
    stdin.on('data', function(chunk) {
        var param = Number(chunk);
        try {
            var result = exports.calculate(param);
            console.log('result: ' + result);
        } catch(e) {
            console.log(e.toString());
        }
    });
};
```



```text:npmtest
~$ npm test
  ......
  ✔ divided#read prints "result: 4" when the value is 8 that given from the stdin
  ✔ divided#read prints "Type of numeric is expected." when the value is not a numeric
  ✔ divided#calculate returns 2 when the value is 4
  ✔ divided#calculate returns 1 when the value is 3
  ✔ divided#calculate throws exceptions when the value is other than numbers

  5 tests passed
```


## test.serial によるテストケースの実行順について 
デフォルトでava は各テストケースを並行(concurently) に実行するようになっていますが、`test.serial` を使用することによって、通常の(`test.serial` じゃない)テストケースよりも先にこれら(`test.serial` な)テストケースをシーケンシャルに実行するようになります。
そのため、広域な場所に定義されている変数に対して変更が発生し、並行実行されることにより他のテストケースに影響を与え、失敗に終わるようなものがあったとしても安全に実行することができます。


```javascript:実行順イメージ((n)が実行順)
// (3) concurent
test('divided#calculate returns 2 when the value is 4', t => { ... });

// (3) concurent
test('divided#calculate returns 1 when the value is 3', t => { ... });

// (3) concurent
test('divided#calculate throws exceptions when the value is other than numbers', t => { ... });

// (1) serial
test.serial('divided#read prints "result: 4" when the value is 8 that given from the stdin', t => { ... });

// (2) serial
test.serial('divided#read prints "Type of numeric is expected." when the value is not a numeric', t => { ... });
```


# hooks を使ってリファクタリングをする 
hooks を使用してテストケース毎に行われる処理を共通化することができます。
ava で使用できるhooks は以下のリンクの通りです。

- Before & after hooks
    - https://github.com/sindresorhus/ava#before--after-hooks

今回のテストでは`beforeEach`, `afterEach` を使用して各テストケースが実行される前と後で`console.log` のバックアップとリストアを行っています。
次の例では`t.context` を使い`beforeEach`, `afterEach` 間でデータを共有できるようにしています(`before`, `after` のhook では使用できません)。


```javascript:test/test-divided.js
import test from 'ava';
import sinon from 'sinon';
import {EventEmitter} from 'events';
import divided from '../index.js';

test.beforeEach(t => {
    t.context.console_log = console.log;
});
test.afterEach(t => {
    console.log = t.context.console_log;
});

test('divided#calculate returns 2 when the value is 4', t => {
    t.is(divided.calculate(4), 2);
});

test('divided#calculate returns 1 when the value is 3', t => {
    t.is(divided.calculate(3), 1);
});

test('divided#calculate throws exceptions when the value is other than numbers', t => {
    t.throws(divided.calculate);
    t.throws(() => divided.calculate(null), 'Type of numeric is expected.');
    t.throws(() => divided.calculate('abc'), / numeric /);
    t.throws(() => divided.calculate([]), TypeError, /^Type of numeric /);
});

test.serial('divided#read prints "result: 4" when the value is 8 that given from the stdin', t => {
    var ev = new EventEmitter();

    sinon.stub(console, 'log');
    process.openStdin = sinon.stub().returns(ev);
    divided.read();
    ev.emit('data', '8');
    t.true(console.log.calledOnce);
    t.true(console.log.calledWith('result: 4'));
});

test.serial('divided#read prints "Type of numeric is expected." when the value is not a numeric', t => {
    var ev = new EventEmitter();

    sinon.stub(console, 'log');
    process.openStdin = sinon.stub().returns(ev);
    divided.read();
    ev.emit('data', 'abc');
    t.true(console.log.calledOnce);
    t.true(console.log.calledWithMatch('Type of numeric is expected.'));
});
```



```text:npmtest
~$ npm test
  ......
  ✔ divided#read prints "result: 4" when the value is 8 that given from the stdin
  ✔ divided#read prints "Type of numeric is expected." when the value is not a numeric
  ✔ divided#calculate returns 2 when the value is 4
  ✔ divided#calculate returns 1 when the value is 3
  ✔ divided#calculate throws exceptions when the value is other than numbers

  5 tests passed
```


## 特定のタイトルのテストケースだけ実行したい場合 
テスト駆動系な開発をしていると、開発作業中は時間節約のため、特定のタイトルのテストのみ実行してほしい場合があります。
ava では`--match=` オプションを付け加えることで、指定したタイトルにマッチしたテストのみを実行することができます。


```text:"divided#calculate"で始まるタイトルのテストケースのみ実行する例
~$ npm test -- --match='divided#calculate*'

~$ ava -v --match='divided#calculate*'
```



```text:"divided#read"で始まるタイトル以外のテストケースのみ実行する例
~$ npm test -- --match='!divided#read*'

~$ ava -v --match='!divided#read*'
```


# 参考 
- ava, sinonを使ったnode.jsのテスト
    - http://qiita.com/akameco/items/c680db49ca46a285bd1a#_reference-ec9b7709d4ce9186252f

- node.jsのCLIのテストをavaで書いてみる
    - http://qiita.com/akameco/items/1248c3d79200f8048fcb

- ava github
    - https://github.com/sindresorhus/ava

- ava-docs ja_JP
    - https://github.com/sindresorhus/ava-docs/tree/master/ja_JP

- Mocha, Chai, Sinon を使ったNode.js のテスト自動化　実践編
    - http://qiita.com/TsutomuNakamura/items/0eb50bf7622a3906e101