JUnit 実践講座 - シナリオベースのテストケースの書き方
JUnit 実践講座 -
シナリオベースのテストケースの書き方
目次
- はじめに
- メソッドベースとシナリオベース
- サンプルプログラム - LoginFormTest クラス
- scenario メソッド
- verify メソッド
- Scenario インナークラス
- テストに応じてスタイルを変えよう
- シナリオベースの問題点について
- 特殊解をあらわにするヘルパーメソッド
はじめに
ここでは,テストケースの具体的な書き方として,シナリオベースのテストケースを紹介します.プログラミングスタイルガイド で述べたように,実際の開発ではテストコードはかなりの規模になります.どうしたらテストコードを読みやすく,メンテナンス性を上げることができるのでしょうか? …僕には,まだこの問題に対する満足な答えを得ることができません.しかし,シナリオベースでテストケースを書けば,場合によってはかなり綺麗にテストケースを書くことができます.これから JUnit で本格的に開発を行なおうという人は,ぜひ参考にしてください.
メソッドベースとシナリオベース
テストケースには,次の2通りの書き方があると思います.
- メソッドベース
- テストするクラスに methodA, methodB という 2つのメソッドがあれば,テストクラスに testMethodA, testMethodB というテストメソッドを用意する.このように,テストするクラスのメソッドをもとにテストメソッドを起こしていく方法.
- シナリオベース
- テストするクラスの使い方として scenarioA, scenarioB という 2つのシナリオがあれば,テストクラスに testScenarioA, testScenarioB というテストメソッドを用意する.このように,テストするクラスのシナリオをもとにテストメソッドを起こしていく方法.
ここで,メソッドベースにおけるメソッドとテストメソッド,シナリオベースにおけるシナリオとテストメソッドの組み合わせは必ずしも 1対1 に対応しているわけではありません.1対多になる場合も多いでしょう.例えば methodA に対して testMethodA1, testMethodA2, ... , scenarioB に対して testScenarioB1, testScenarioB2, ... という具合にテストメソッドを作成することになると思います.
この 2つの書き方の違いは,テストするクラスをどういう切り口で見ているか,ということからきています.つまり,そのクラスをメソッド単位で見るほうがいいのか,シナリオ単位で見るほうがいいのか,ということです.どちらの書き方になるかはテストするクラスに依存します.例えば,次の表のようになるでしょう.
クラスの種類 テストケースの書き方 データを保持するのが目的のクラス メソッドベース ユーティリティ的なクラス メソッドベース ふるまいを表すクラス シナリオベース
もちろん,メソッドベース,シナリオベース両方が必要なクラスもあるので,常にどちらか一方の書き方になるわけではありません.
メソッドベースで書いたほうがよいクラスの代表例は java.lang.Math クラスでしょう.絶対値を求める Math.abs や最大値を求める Math.max のテストケースは, MathTest.testAbs, MathTest.testMax というテストメソッドで書くのがいいでしょう.
一方,「ふるまいを表すクラス」というのは,メソッドの呼び出し順序がある程度決まっているクラスのことです.例えば java.util.StringTokenizer クラスは,ある程度使い方が決まっているため「ふるまいを表すクラス」ということになります.このクラスの典型的な使われ方は次のようになるでしょう.
StringTokenizer st = new StringTokenizer("this is a test"); while (st.hasMoreTokens()) { System.out.println(st.nextToken()); }
こういったクラスに対して,testConstructor, testHasMoreTokens, testNextToken というテストメソッドを書くのは間違いです.いろいろなテストパターン(この場合は StringTokenizer のコンストラクタに与える引数)の元で,上のシナリオをテストするのが望ましいと言えます.例えば,空の文字列でテストしたり,空白が一つもない文字列でテストしたり…というテストが考えられますね.だから, testEmptyString, testOneTokenString というテストメソッドを用意すればよいのです( 1つのシナリオに対して複数のテストメソッドが対応しています).こういったテストをここでは「シナリオベースのテストケース」と呼んでいます.
サンプルプログラム - LoginFormTest クラス
メソッドベースのテストケースは構成しやすいのですが,シナリオベースのテストケースはわかりやすく書くのが難しいです.ここでは,シナリオベースのテストコードをどうやって書けばよいか,サンプルプログラムを使って説明しましょう.
まず,ユーザID とパスワードを入力してログイン処理を行なう LoginForm クラス を考えてみましょう.Web アプリでは,こういったクラスは jsp から JavaBean として利用されることになります.この LoginForm のシナリオは,例えば次のようになるでしょう.
LoginForm form = new LoginForm(); form.setUserId("user1"); form.setPassword("password1"); form.execute(); String message = form.getMessage();
このプログラムは,LoginForm にユーザID とパスワードをセットし,ログイン処理を行い( execute メソッド), 最後に getMessage でメッセージを取得する,という単純なものです.これをテストコードにするためには,最後の getMessage のところで assertEquals を使います.テストコードを LoginFormTest クラスの test メソッドとして書いてみましょう.
public class LoginFormTest extends TestCase { public void test() throws Exception { LoginForm form = new LoginForm(); form.setUserId("user1"); form.setPassword("password1"); form.execute(); assertEquals("こんにちは,ユーザ1さん!", form.getMessage()); } }
ここで,LoginForm.getMessage メソッドはユーザID からその人の名前を取り出し,「こんにちは,ユーザ1さん!」というメッセージを返すものとします( user1 の名前は「ユーザ1」と仮定しています).また,ここからは特に断らない限りメソッドやインスタンス変数は LoginFormTest クラスに属しているものとします.
scenario メソッド
実際には,このシナリオをいろいろなパターンでテストする必要があります.別のユーザID でテストしたり,パスワードが不正な場合のテストも必要でしょう.でもシナリオは共通です.そこで,LoginFormTest クラスに scenario メソッド を導入します.
// scenario メソッド private void scenario() throws Exception { form = new LoginForm(); form.setUserId(userId); form.setPassword(password); form.execute(); assertEquals(expectedMessage, form.getMessage()); } // テストするクラスのインスタンス private LoginForm form;
次に,テストパターン用として使うパラメータも LoginFormTest のインスタンス変数として宣言しておきます.
//テストパラメータ変数
private String userId;
private String password;
getMessage メソッドの期待値も expectedMessage として宣言します.
// expected 変数
private String expectedMessage;
こうすれば,先ほどの test メソッドは次のように書けます.
public void testUser1() throws Exception { userId = "user1"; password = "password1"; expectedMessage = "こんにちは,ユーザ1さん!"; scenario(); }
わかりやすくするため,メソッド名を testUser1 に変更しました.別のユーザ user2 のテストも書いてみましょう.
public void testUser2() throws Exception { userId = "user2"; password = "password2"; expectedMessage = "こんにちは,ユーザ2さん!"; scenario(); }
さらに,不正なパスワード入力のテストも次のように書けるでしょう.
public void testBadPassword() throws Exception { userId = "user1"; password = "password2"; try { scenario(); fail(); } catch (UserInputException ex) { assertEquals("パスワードまたはユーザID が違います", ex.getMessage()); } }
ここで, LoginForm は入力が不正な場合 UserInputException をスローすると仮定しています.以上がシナリオベースのテストケースの典型的なものです.まとめると,シナリオベースのテストケースは次のような構成要素から成ります.
- scenario メソッド
- テストするクラスのシナリオを書くメソッド.private scenario() throws Exception というシグニチャを持つ.
- テストパラメータ変数
- scenario メソッドで利用されるテストパターンを表すインスタンス変数.
- expected 変数
- 期待値を表すインスタンス変数.assertEquals の第1引数となる.expected というプレフィックスをつける.
こういったコーディング規約でテストケースを書けば,次のようなメリットがあります.
- そのクラスの使い方を知りたければ,まず テストケースの scenario メソッドを見よう
scenario メソッドに典型的な使い方が書いてあるため,クラスの利用者はその中身を見て使い方を理解できます.また,そのコードをコピー&ペーストで簡単に利用できます.scenario メソッドが仕様書およびサンプルコードとして機能します.
- そのクラスを使ったときの効果を知りたければ,各テストメソッドのテストパラメータ変数と expected 変数の値をチェックしよう
各テストメソッドには,scenario メソッド呼び出しの他はテストパターンと結果のみが書かれています.このため,一旦 scenario メソッドの中身を頭に入れてしまえば,そのクラスはどういう状況でどういう効果があるかはっきりします.
さて,ここでもう少し複雑なサンプルを見ていただきましょう.紹介するのは, Base64 でエンコードを行なう Base64EncodeOutputStream クラスのテストケースです( Base64EncodeOutputStreamTest.java ).このクラスは,雑誌 Unix User で 結城 浩さん が JUnit のサンプルとして取り上げておられました.それを僕がシナリオベースに書き換えたものです(結城さんの許可をもらって掲載しています).scenario メソッド,テストパラメータ変数,expected 変数の 3つがそろっていることを確認して各テストメソッドをご覧下さい.このテストコードは本来ならコメントを書くべきですが,今回は省略しています.
verify メソッド
テストケースによっては,テスト結果を検証するコードが複雑になることがあります.その部分を verify メソッド というメソッドで共有すれば,テストコードがすっきりします.先ほどの LoginFormTest クラスは簡単すぎるため verify メソッドを使う理由はほとんどありませんが,例として書いてみましょう.まず,テストクラスに実行結果を格納するための actual 変数 を導入します.
// actual 変数
private String actualMessage;
次に,scenario メソッドを書き換えて assertEquals を取り除きます.
private void scenario() throws Exception { form = new LoginForm(); form.setUserId(userId); form.setPassword(password); form.execute(); actualMessage = form.getMessage(); }
このとき, verify メソッドは,次のようになります.
// verify メソッド
private void verify() throws Exception
{
assertEquals(expectedMessage, actualMessage);
}
テストメソッドでは scenario メソッドの次に verify メソッドを呼ぶことになります.
public void testUser1() throws Exception { userId = "user1"; password = "password1"; expectedMessage = "こんにちは,ユーザ1さん!"; scenario(); verify(); }
こうすれば,scenario メソッドから検証コードが省け,scenario メソッドをサンプルコードとして理解しやすいものにすることができます.また,上の verify メソッドで actual 変数を使ったのは, scenario メソッドに LoginForm の使い方をすべて記しておきたかったためです.一般に,scenario メソッドは,テストするクラスの典型的な使い方を一望できるような構成にするのがよいでしょう.もし LoginForm.getMessage をシナリオとして載せる必要がないのなら, actual 変数を省略して次のようにしてもかまいません.
// actual 変数を使わない verify メソッド
private void verify() throws Exception
{
assertEquals(expectedMessage, form.getMessage());
}
まとめると,シナリオベースのテストケースに verify メソッドを導入すれば,次の構成要素が追加されることになります.
- verify メソッド
- シナリオを検証するメソッド.private verify() throws Exception というシグニチャを持つ.
- actual 変数
- 実測値を表すインスタンス変数.assertEquals の第2引数となる.actual というプレフィックスをつける.
なお,テストするクラスによっては, シナリオが多様で verify メソッドのみを使いまわしした方がいい場合もあります.そのときは,各テストメソッドで scenario メソッドを使わずインラインでシナリオを書き,最後に verify メソッドを呼ぶというふうにしましょう.
Scenario インナークラス
ここでは,複数のシナリオがあり,テストが複雑になるケースを考えてみましょう.こういう場合は scenario メソッドの種類を増やせば十分なことも多いでしょう.
private scenarioFoo() throws Exception { // シナリオ Foo をここに書く } private scenarioBar() throws Exception { // シナリオ Bar をここに書く }
さらにもっと複雑で,シナリオごとに異なるテストパラメータを使ったテストをしなければならない場合を考えます.そのときは,テストケースの中に Scenario インナークラスを定義し,そのなかでテストパラメータ変数や expected 変数を定義するようにします.
private static class LoginScenario { // テストするクラスのインスタンス LoginForm form; // テストパラメータ変数 String userId; String password; // expected 変数 String expectedMessage; // actual 変数 String actualMessage; // scenario メソッド void excecute() throws Exception { form = new LoginForm(); form.setUserId(userId); form.setPassword(password); form.execute(); actualMessage = form.getMessage(); } // verify メソッド void verify() throws Exception { assertEquals(expectedMessage, actualMessage); } }
今まで LoginFormTest クラスに直接書いていたものを LoginScenario インナークラスに移しただけです.scenario メソッドだけ LoginScenario.execute メソッドと名前を変えていることに注意してください.この LoginScenario を使ったテストメソッドは次のようになります.
public void testUser1() throws Exception { LoginScenario scenario = new LoginScenario(); scenario.userId = "user1"; scenario.password = "password1"; scenario.expectedMessage = "こんにちは,ユーザ1さん!"; scenario.execute(); scenario.verify(); }
Scenario インナークラスに setUp メソッドを導入したほうがよい場合もありますが,大抵の場合,そういった初期化処理はコンストラクタで書いてしまえば十分でしょう.重要なのは,テストコードを一定のネーミングルールに従って書く,ということです.そうすればテストクラスを読む人はこのクラスを使うにはどの部分をみればいいか,ということがすぐに分かるようになります.
テストに応じてスタイルを変えよう
これまでいろんなスタイルのテストケースを見てきました.しかし,この中から一つのスタイルを選び,コーディング標準としてすべてのテストケースに適用するのは避けてください.例えば,この中で一番汎用性があるのは Scenario インナークラスでしょう.しかし,単純なテストケースに対してこのスタイルを使うのは間違いです.誤ったスタイルでテストケースを書けば,テストコードがわかりにくくなり,可読性を落とすことになります.同様に,scenario メソッドが一箇所しか利用されていないのに,早くから scenario メソッド を導入するのは止めましょう.テストに応じてもっとも簡単なスタイルを選択してください.テストコードはシンプルなのが一番いいのです.
僕が勧めるのは,今行なっているテストに応じてスタイルを進化させていく方法です.一番最初は,テストメソッドが一つだけのもっとも単純なスタイルから始めます.
public class FooTest extends TestCase { public void test() throws Exception { .... } }
このとき,テストメソッド名も一番簡単な test にしておきます.その後テストメソッドを追加していくことになったとしましょう.この段階で同じシナリオが使われているなと思ったら,そこで初めて scenario メソッドを導入します.
public class FooTest extends TestCase { private void scenario() throws Exception { .... } public void testFoo() throws Exception { .... scenario(); .... } public void testBar() throws Exception { .... scenario(); .... } }
あるいは, シナリオではなく検証コードが同じだな,と思ったら verify メソッドを導入します.
public class FooTest extends TestCase { private void verify() throws Exception { .... } public void testFoo() throws Exception { .... verify(); .... } public void testBar() throws Exception { .... verify(); .... } }
以下同様に,scenario メソッドと verify メソッドの両方が必要だなと思った時点で scenario, verify メソッドを導入し,さらに Scenario インナークラスを導入したほうがいいな,と思った時点で Scenario インナークラスを書いてください.最初からスタイルを決めてテストケースを書かないようにしましょう.
シナリオベースの問題点について
シナリオベースのテストケースは,本質的にシナリオの多様性に対応できないという問題を持っています.例えば,単純な LoginFormTest でも ユーザID やパスワードの未入力のテストを scenario メソッドで共有することができません.
public void testUserIdRequired() throws
{
try {
form = new LoginForm();
// ここで form.setUserId("user1") を呼ばない!
form.setPassword("password1");
form.execute();
}
catch (UserInputException ex) {
assertEquals("ユーザID を入力してください", ex.getMessage());
}
}
もし共有しようと思えば,テストパラメータのヌルチェックでメソッドを呼ぶかどうか判断する必要があるでしょう.
//未入力チェック対応の scenario メソッド
private void scenario() throws Exception
{
form = new LoginForm();
if (userId != null)
form.setUserId(userId);
if (password != null)
form.setPassword(password);
form.execute();
assertEquals(expectedMessage, form.getMessage());
}
public void testUserIdRequired() throws
{
userId = null;
password = "password2";
try {
scenario();
fail();
}
catch (UserInputException ex) {
assertEquals("パスワードまたはユーザID が違います", ex.getMessage());
}
}
確かにこれで問題ないのですが,こうすれば scenario メソッドが複雑になり,クラスの利用者がコピー&ペーストで簡単にコードを利用することができなくなります.さらにこの方法は次のテストメソッドですぐに破綻してしまいます.
// setUserId より先に setPassword を呼ぶテスト
public void testSetPasswordFirst() throws
{
form = new LoginForm();
form.setPassword("password1");
form.setUserId("user1");
form.execute();
assertEquals("こんにちは,ユーザ1さん!", form.getMessage());
}
こういった問題について, 僕は そもそも対応しない というスタンスを取っています.例えば,この未入力チェックでメソッドを呼ばないテストが本当に重要でしょうか? ひょっとすると setUserId メソッドを呼ばない代わりに
form.setUserId(null);
で十分なのかもしれません.そうすれば元の scenario メソッドを共有できます.また,メソッドの呼び出し順序を入れ替えるテストが他のテストに比べて重要でしょうか? もしそのシナリオをテストしていなかったためにバグが発生したというなら話は別ですが,起こりそうにないのなら,ある程度妥協してテストコードを書いていくことをお勧めします.そうしないと,テストコードがどんどん大きくなり,他の重要なテストを書く時間がなくなってしまいます.あまりにシナリオが多様すぎる場合, verify メソッドだけでも共有できないか考えてみてください.僕には,他にいい方法を思いつきません.
特殊解をあらわにするヘルパーメソッド
以上でシナリオベースの書き方は終わりですが,ここでもう少し補足しておきましょう.実際の LoginForm の実装は,データベースのユーザマスタを検索して認証を行なうことが多いです.つまり,LoginForm のテストはバックグラウンドのデータベースに依存しているのです.上のサンプル: LoginFormTest はデータベースに依存したテストになっているため,あまりいいテストとはいえません.例えば,user1 の名前は「ユーザ1」であるという情報はデータベースを見ない限り分からないのです.
そこで, 特殊解をあらわにするヘルパーメソッド を導入します.LoginFormTest の場合には,データベースにアクセスしてユーザ登録を行なうメソッド setUser を用意しておきます.
private void setUser(String userId, String password, String userName) throws Exception
{
// ここにユーザマスタにユーザを登録するコードを書く
}
このヘルパーメソッドを利用して testUser1 を次のように書き直します.
public void testUser1() throws Exception { setUser("user1", "password1", "ユーザ1"); userId = "user1"; password = "password1"; expectedMessage = "こんにちは,ユーザ1さん!"; scenario(); }
これで user1 と password1 , 「ユーザ1」の関係が明らかになり,データベースの内容に依存しないテストコードになります.ポイントは,特殊解がすべてテストメソッドの中に収まるようになっているということです.ヘルパーメソッド内部に特殊解を書いてしまってはいけません.あくまで特殊解を引数としてテストメソッドから呼ぶようにします.そうすれば,各テストメソッドを見るだけで特殊解がはっきりわかるようになります.また,ヘルパーメソッドを scenario メソッドに入れてもいけません. scenario メソッドにはあくまで テストするクラスのシナリオだけを書くようにしてください.
この特殊解をあらわにするヘルパーメソッドは,各テストメソッドの一番最初に書いてテストのセットアップに用いられることが多いです.
- 特殊解をあらわにするヘルパーメソッド
- テストメソッドのコンテクストを設定するためのメソッド.特殊解の要素を引数に持つ.
このメソッドを使うことは,プログラミングスタイルガイド で述べた「テスト対象クラスに関する問題領域に直接関係ないところでの抽象化」に相当します.このメソッドを導入すべきかどうかは状況によります.例えば,user1 のユーザ情報を開発者間の暗黙の了解として採用でき,テスト用データベースにあらかじめ登録しておこう,とするのであれば,このメソッドを使わずにテストコードを書いてしまっても問題はありません.そうすれば,テストコードを書く負担を減らすことができます.