Edited at

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

More than 3 years have passed since last update.

先日Mocha でテストの自動化記事を書きましたが、@akameco 氏の記事でava を取り上げていましたので、今度はava を使って同じプログラムをテストしてみることにします。

多々コード等を参考にしている部分がありますので、@akameco 氏にはこの場を借りてお礼を申し上げます。ありがとうございます<(_ _)>


ava で始めるnode のテスト自動化

ava の特徴としては主に以下のようなものがあります(他にもあります)。

* 各ファイルが別プロセスとしてtest される(他のtest ケースの環境変更が他のtest ケースに影響を及ぼさない)

* 各テストケースが並行で実行される

* Ecma Script 6 対応


準備


ディレクトリ構成

nodeprj/                // <- ワークディレクトリ

index.js // <- テスト対象のプログラム
+--test/
+--test-divided.js // <- divided.js をテストするプログラム

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


ディレクトリの作成

~$ mkdir -p nodeprj/test

~$ cd nodeprj

npm init を使用してプロジェクトを初期化します。

入力する内容例としては下記のとおりです。

~$ 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 に追記していきます。


インストール

~$ 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 ファイルに依存関係として追記されるようになります。


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 ファイルを新規に作成し、以下のプログラムを作成します。


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 が呼びだされます。


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 メソッドを実装し、再度テストを実行してみましょう。


index.js

'use strict'

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



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


テストをパスしました。

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


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);
});



npmtest

~$ npm test


NodeUnitAva_0000.png

想定している結果が1 であるのに1.5 が返ってきているためエラーとなっています。

エラーの内容に従ってプログラムの内容を修正し、再テストします。


index.js

'use strict'

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



npmtest

~$ npm test


NodeUnitAva_0001.png


-w, --watch オプションでファイルの変更が発生したことを契機にテストを自動実行する

ava のテストコマンドに-w, --watch オプションをつけると、テストコマンドが待ち受け状態に入り、テストコードやテスト対象ファイルに変更が発生したタイミングで自動的にテストを実行してくれるようになります。

また、npm test コマンドではpackage.json ファイルに記載されたテストコマンドとオプションで実行するようになっていますが、そのコマンドに追加でオプションを指定したい場合はnpm test -- <追加オプション> という形式で指定します。


--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 なスタイルで記述することができるので、先ほど書いたテストケースは次のように書くこともできます。


functionを使った書き方

test('divided#calculate returns 2 when the value is 4', function(t) {

t.is(divided.calculate(4), 2);
});

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


function名をタイトルとして使用する

test(function divided#calculate_returns_2_when_the_value_is_4(t) {

t.is(divided.calculate(4), 2);
});

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


タイトルを省略する

test(function (t) {

t.is(divided.calculate(4), 2);
});

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



例外を判定するテストを作成する

プログラムに数値以外の値が引数に渡された時に例外を投げることを確認するテストケースを作成します。

例外が投げられることを確認するにはthrows function を使用します。


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 /);
});



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.


例外が投げられていないため、テストに失敗しました。

プログラムに例外をスローするテストケースを追加し、再テストします。


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);
};



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 を使用してイベントを非同期に呼び込む処理をエミュレートし、テストを行います。


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;
});



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 を追加し、再テストしてみましょう。


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);
});
};



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.' が表示されることを想定しています。


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 を使用するようにしています。


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.


例外がスローされなかったためテストに失敗しています。

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


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());
}
});
};



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 な)テストケースをシーケンシャルに実行するようになります。

そのため、広域な場所に定義されている変数に対して変更が発生し、並行実行されることにより他のテストケースに影響を与え、失敗に終わるようなものがあったとしても安全に実行することができます。


実行順イメージ((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 は以下のリンクの通りです。

今回のテストではbeforeEach, afterEach を使用して各テストケースが実行される前と後でconsole.log のバックアップとリストアを行っています。

次の例ではt.context を使いbeforeEach, afterEach 間でデータを共有できるようにしています(before, after のhook では使用できません)。


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.'));
});



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= オプションを付け加えることで、指定したタイトルにマッチしたテストのみを実行することができます。


"divided#calculate"で始まるタイトルのテストケースのみ実行する例

~$ npm test -- --match='divided#calculate*'

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



"divided#read"で始まるタイトル以外のテストケースのみ実行する例

~$ npm test -- --match='!divided#read*'

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



参考