実践、ユニットテスト 第1回 ユニットテストのポイント
テストの基本知識はあるが、今まで実際のプロダクトでテストを書いたことがない人、自分が書いたコードのどこをテストしたらいいかわからない人のために、まずはテストを書いてみることを目的として、足がかりとなるようなポイントを解説します。
はじめに
ユニットテストは、自分が書いたコードが正しく仕様を満たしているか、バグが生まれていないか、などを検証するために行うものです。そのため、本来であれば、仕様として定義されている処理に対して考えられるパターンのテストを書き、そのすべてを通過するかどうかをチェックする必要があります。
ですが、この記事では、テストの基本知識はあるが、今まで実際のプロダクトでテストを書いたことがない人、自分が書いたコードのどこをテストしたらいいかわからない人のために、まずはテストを書いてみることを目的として、足がかりとなるようなポイントを解説していこうと思います。
なお、この記事ではテストの基礎知識や、テストのための環境構築方法については触れていません。基礎や環境構築については、JavaScript開発のためのテスト入門シリーズに詳しく書かれています。このシリーズを読んでいない方は、先に目を通しておくと理解の助けになるかと思います。
本記事のサンプルのテストコードは、mochaとexpect.jsおよび、Sinon.jsを使って書いていきます。筆者個人が日常業務でよく使っているので、今回はこの組み合わせで書いていますが、他のものを使っていただいてもかまいません*。
*注:テストのための予備知識
テストフレームワークや、Sinon.jsの使い方に関しては次の記事なども参照してください。
なお、以降で紹介するコードは次のリポジトリからダウンロード、またはクローンできます。併せて参照してください。
実践、ユニットテストリポジトリ
テストしやすいところからテストする
いきなりプロダクトコード*を開いて、はじめの処理から順にテストを書いていく、というのは最初は難しいかもしれません。まずは、テストが書きやすい部分から書き始め、徐々に全体の処理のテストに移っていくようにすると書きやすいと思います。その際は、処理の元となっている仕様や期待する挙動に沿って、テストコードを書いてみましょう。
*注:プロダクトコードとテストコード
実際に動くアプリケーションやライブラリのコードのことをプロダクトコードと呼び、ユニットテストやE2Eテストを実行するコードのことをテストコードと呼びます。
単純な処理をテストする
先程も述べましたが、テストとは処理が正しく実装されているかを検証するためのものです。ですから、処理が単純なコードほど、テストがしやすいと言えます。まずは、自分が書いたコードの中から、単純な処理を行っている箇所を探し出して、そこからテストを書き始めてみると良いでしょう。
単純と言っても、ただ固定値を返すだけの関数ではテストを書くメリットがないので、簡単な条件分岐を含む処理などがちょうどいいです。
ここでは単純な処理の例として、引数が過去の日付かどうかを調べるコードを挙げてみます。このコードは、次のような挙動を期待しているとします。
過去の日付かどうかを調べる
* 引数が過去の日付の場合は`true`を返す
* 引数が今日、もしくは未来の場合は`false`を返す実際のコードは、次のとおりです。
function isPast(date) {
var now = Date.now();
return date.getTime() < now;
}引数として渡された値のタイムスタンプと、今日の日付のタイムスタンプとを比較して、小さければtrueを返し、同じ、または大きければfalseを返しています。
ではこのコードのテストを書いてみましょう。
isPast()関数のテスト
describe('isPast', function() {
var clock;
var fakeTime = (new Date('2016/5/1')).getTime();
// 日付のスタブ*を作成する
beforeEach(function() { clock = sinon.useFakeTimers(fakeTime); });
afterEach(function() { clock.restore() });
it('引数が過去の日付の場合はtrueを返す', function() {
var date = new Date('2016/4/1');
expect(isPast(date)).to.be(true);
});
it('引数が今日、もしくは未来の場合はfalseを返す', function() {
var date = new Date();
expect(isPast(date)).to.be(false);
date = new Date('2016/6/1');
expect(isPast(date)).to.be(false);
});
});*注:スタブ
スタブとは、あるメソッドを上書きし、元の処理を簡単なものに置き換えるためのテストダブルです。
引数が過去の日付かそうでないかで、trueまたはfalseを返しているかどうかを検証するテストです。Dateクラス*を使っているため、そのままではテストを実行した日付によって結果が変わってしまいます。そこで、sinon.useFakeTimers()を使って、Dateクラスが特定の日付を返すようにしています。これで、現在の日付を偽装することができます。
*注:Dateクラス
Dateクラスは1 January 1970 00:00:00 UTC (Unix Epoch).からのミリ秒数を表す整数値を返します。引数を与えない場合、コンストラクタは現在の日付と地方時による時刻を表すJavaScriptのDateオブジェクトを生成します。
このように単純な処理であれば、テストを書くことは難しくありません。まずはこのような箇所を探してテストしてみることから始めてみましょう。
コラム:to.be(true)とto.be.ok()
expect.jsには、結果がtruthy(!!fooとしたときにtrueとなるような値)かどうかを検証する、.to.be.ok()メソッドがあります。
このようにtrueかfalseで値を返すものであれば、.to.be.ok()を使えば良いと思うかもしれません。
ですが、筆者個人としては、自分が書いたコードでboolean型を返しているのであれば、.to.be(true)を使った方が良いと考えます。テストは仕様を反映して書かれているので、逆に言えば仕様書の代わりになるとも言えます。後から見返したときに、.to.be(true)となっていれば、「この関数はboolean型を返すんだな」とわかりますが、.to.be.ok()となっていると、「truthyなのはわかるが何を返すかわからない」となってしまうこともありえます。そのため、仕様としてboolean型を返すことが求められているのであれば、テストもそのようにあったほうが良いと考えています。
筆者の場合は、ライブラリが返す結果(sinon.jsのcalledOnce)などに関しては.to.be.ok()を使う、などとして使い分けています。
他の関数を使用した小さな処理をテストする
単純な処理のテストを書いたら、次はそれらを使った小さな処理のテストを書いてみましょう。サンプルとして、期限と完了状態からToDoリストのステータスを返す関数を挙げてみます。
この関数は、次のような仕様を元に書かれているとします。
期限と完了状態からステータスを表す文字列を返す
* すでに完了している => `closed`を返す
* 完了しておらず、期限を過ぎている => `runout`を返す
* 完了しておらず、期限を過ぎていない => `open`を返すコードは次のとおりです。
// 期限と完了状態からステータスを文字列で返す
// @param due 期限
// @param closed 完了状態
function getStatus(due, closed) {
if(closed) {
return 'closed';
}
return isPast(due) ? 'runout' : 'open';
}引数で渡された期限dueと、完了状態closedから、ステータスを表す文字列を返すコードです。期限を判定する処理に、先程作ったisPast()関数を使っています。
引数closedがtrueの場合は、文字列'closed'を返し、そうでない場合は期限を過ぎていないかを調べています。isPast()関数に引数dueを渡し、trueが返ってきたら'runout'を、そうでなければ'open'を返しています。
前のコードよりも少しだけ処理の内容が増えています。では、この処理のテストを書いてみましょう。
getStatus()関数のテスト
describe('getStatus', function() {
var clock;
var fakeTime = (new Date('2016/5/1')).getTime();
beforeEach(function() { clock = sinon.useFakeTimers(fakeTime); });
afterEach(function() { clock.restore() });
context('完了している場合', function() {
var closed = true;
it('文字列closedを返す', function() {
var due;
expect(getStatus(due, closed)).to.be('closed');
});
});
context('完了していない場合', function() {
var closed = false;
it('期限を過ぎていなかったら文字列openを返す', function() {
var due = new Date();
expect(getStatus(due, closed)).to.be('open');
due = new Date('2016/6/1');
expect(getStatus(due, closed)).to.be('open');
});
it('期限を過ぎていたら文字列runoutを返す', function() {
var due = new Date('2016/4/1');
expect(getStatus(due, closed)).to.be('runout');
});
})
});*注:context
contextは、主に条件によって複数の値を返す場合に、テストコードを読みやすくするために使います。実態はdescribeのエイリアスです。
引数dueとclosedのそれぞれのパターンで、getStatus()関数を実行し、正しい文字列が返ってきているかを検証しています。関数内部で呼び出しているisPast()関数が日付を扱うため、ここでもsinon.useFakeTimers()を使って、日付のスタブを用意しています。
しかしながらこれでは、この先isPast()関数を使った処理が出てくるたび、日付のスタブを用意しなければならず、大変そうです。ユーティリティ関数を作って共通化をしてもいいのですが、ここではsinon.stub()を使ってみます。
getStatus()関数のテスト(sinon.stubを使用)
describe('getStatus (use stub)', function() {
var due;
beforeEach(function() {
sinon.stub(window, 'isPast');
isPast = window.isPast; // テスト対象がグローバル関数なので再代入が必要*
});
afterEach(function() {
window.isPast.restore()
isPast = window.isPast;
});
context('完了している場合', function() {
var closed = true;
it('文字列closedを返すこと', function() {
expect(getStatus(due, closed)).to.be('closed');
});
});
context('完了していない場合', function() {
var closed = false;
it('期限を過ぎていなかったら文字列openを返す', function() {
window.isPast.returns(false);
expect(getStatus(due, closed)).to.be('open');
});
it('期限を過ぎていたら文字列runoutを返す', function() {
window.isPast.returns(true);
expect(getStatus(due, closed)).to.be('runout');
});
});
});*注:テスト対象がグローバル関数なので再代入が必要
今回のサンプルでは、テスト対象がグローバル関数だったため、isPast = window.isPast;の行が必要になってしまっています。通常のオブジェクトやクラスのメソッドをスタブ化する場合は、この行は不要です。
sinon.stub()を使って、isPast()関数をスタブ化しました。isPast()はすでにテストされており、isPast()の結果によって、getStatus()の処理が正しく変わるかどうかを検証するようにしています。こうすることで、sinon.useFakeTimers()を使う必要がなくなり、テストの記述が少しだけ減りました。
また、たとえばisPast()の仕様が変わってDate型の日付を利用しなくなったとします。その場合も、isPast()のテストだけ書き直せばよく、getStatus()は変更しなくてもよくなります。
ただし、やみくもにスタブにしてしまえばいいというものでもありません。テストが実装に依存してしまうので、リファクタリングの妨げになることもあります。
たとえば、isPast()関数と同等の機能を持つライブラリを見つけたので、そちらを使うようにしたとします。その場合、そのisPast()関数のスタブを作成していたところは、すべてそのライブラリのスタブを作るように変えなくてはなりません。
また、スタブを作ることができるのは、対象のメソッドが十分にテストされている場合です。テストが不十分な場合、テストは通るが画面では動かない、といったことが起こる可能性があります。
このように、スタブを使うことで逆効果になってしまう場合もあるので、使いすぎには注意が必要です。
テストを書いたほうが良いところ
ここまでは、簡単な処理を探し出し、期待した処理が行われるかのテストを書きました。どちらかと言えば実装面で、「テストを書きやすい部分」をテストする方法を解説しました。
テストを書く要領がつかめたら、次は、視点を変えて仕様面から探してみましょう。仕様的に重要な処理に関しては、テストを書くことによって受ける恩恵が大きいです。つまりこちらは「テストを書いたほうが良い部分」です。
バグが生まれやすい処理をテストする
処理が複雑になってくると、必然的にバグは生じやすくなってきます。そのような箇所はもちろんテストしたほうが良いのですが、ここでは単純な処理であってもバグが生じやすいものを例に挙げてみます。
先ほど作ったisPast()関数は、Date型以外の値であっても受け取ることができます。そこで、Date型以外だった場合はエラーになるように仕様を追加してみます。
仕様は次のようになります。
過去の日付かどうかを調べる
* 引数が過去の日付の場合は`true`を返す
* 引数が今日、もしくは未来の場合は`false`を返す
* 引数がDate型でなければエラー実際のコードは次のとおりです。
Date型以外はエラーとなる
function isPast(date) {
var now = Date.now();
// Date型でない場合はエラー
if(!(date instanceof Date)) {
throw Error('引数が正しくありません');
return;
}
return date.getTime() < now;
}では、これもテストを書いてみます。
isPast()関数のテスト
describe('isPast', function() {
// 中略
it('引数がDate型ではない場合エラーになること', function() {
expect(function() { isPast('2016/4/1') }).to.throwException('引数が正しくありません');
});
});テストを実行すると、次のようになります。
テストも通過して、一見正しく動くように思えますが、引数dateがInvalid Dateだった場合、date.getTime()はNaNとなり、isPast()はfalseを返すようになります。
function isPast(date) {
// 中略
if(!(date instanceof Date) || isNaN(date.getTime())) {
// 中略
}
return date.getTime() < now;この結果をバグとするかどうかは仕様次第なのですが、ここではバグとして扱い、修正した上でテストを書いておきます。
isPast()関数のテスト
describe('isPast', function() {
// 中略
it('引数がDate型ではない場合エラーになること', function() {
expect(function() { isPast('2016/4/1') }).to.throwException('引数が正しくありません');
});
it('引数がInvalid Dateの場合エラーになること', function() {
expect(function() { isPast(new Date('hoge')) }).to.throwException('引数が正しくありません');
})
});テスト結果は、次のようになります。
こういったちょっとしたミスから生まれるバグは、なるべく多くのパターンでテストを書いておくことで見つけるができます。ですが、テストするパターンが増えれば増えるほどテストの実行やメンテナンスなどのコストも増えてしまいます。開発リソースと機能の重要度から、どこまでテストするかの判断が必要になってきます。
データを保存する処理をテストする
APIに対してPOSTやPUTなどで、クライアントサイドにあるデータをサーバーサイドに送信する処理は、バグによる影響が大きいため、テストを書くことで早期に発見できることが望ましい場合もあります。クライアントサイドでバリデーションを行うこともあるでしょう。そういったところも、テストを書くことによって受ける恩恵は大きいと思われます。
メールアドレスのバリデーション
// 入力値がメールアドレスか簡易に調べる
function isValidEmail(str) {
var reg = /^([a-z0-9_]|\-|\.|\+)+@(([a-z0-9_]|\-)+\.)+[a-z]{2,6}$/i;
if(typeof str !== 'string') {
return false;
}
if(!reg.test(str)) {
return false;
}
return true;
}テストは次のようになります。
isValidEmail関数のテスト
describe('isValidEmail', function() {
context('メールアドレスとして正しい文字列が入力された場合', function() {
it('trueを返す', function() {
expect(isValidEmail('[email protected]')).to.be(true);
});
});
context('メールアドレスとして正しくない文字列が入力された場合', function() {
var args = [
'test@example',
'test()@example.com'
];
args.forEach(function(arg) {
it(arg + 'のときfalseを返す', function() {
expect(isValidEmail(arg)).to.be(false);
});
})
});
context('文字列以外の値が渡された場合', function() {
var args = [
['0のとき', 0],
['配列のとき', []],
['オブジェクトのとき', {}],
['booleanのとき', true],
['undefinedのとき', undefined],
['nullのとき', null],
['NaNのとき', NaN]
];
args.forEach(function(arg) {
it(arg[0] + 'falseを返す', function() {
expect(isValidEmail(arg[1])).to.be(false);
});
});
});
});テストを実行してみましょう。
また、ローカルストレージやセッションストレージを使って何らかの値を保存している場合も、同様にバグによる影響が大きいです。サーバーサイドでデータベースなどに保存する場合は、サーバーサイドのプログラムによって、重大なバグが起きるのを防いでいることも期待できますが、ローカルストレージやセッションストレージは、クライアントサイドの処理のみで保存されるので、一層の注意が必要です。
とは言え、ユーザーの入力値など、プログラム側でコントロールすることができないデータを送信・保存する場合は、入力されるすべてのパターンで検証することは、どうやっても不可能です。テストを書くことで、実装したコードのバグを発見することはできますが、実装漏れを探すことはできません。しっかりバリデーションを行うなど、プロダクトコード側での工夫が必要になってきます。
まとめ
今回はユニットテストの最初の一歩として、「どこをテストするか」という点で、テストのポイントについて解説しました。
最初は単純な処理からユニットテストをしていき、テストを書くことに慣れてきたら、「ここがバグってたら困るなぁ」と思うようなところを、テストしていきましょう。
次回からは実際にありそうなWebアプリケーションのコードを使って、ユニットテストのポイントとコードの設計について解説します。