総当たりテストをもれなく行う
複数条件の総当たりテストをもれなく行うためのユーティリティを書いてみました。
- 「条件」ごとに「取り得る値」を登録しておき、
- テスト実行で、「取り得る値」を順番に変えて、総当たりで取り得るすべてのパターンを生成。パターンごとに対応する評価ロジックを実行して結果を確認。
- パターンに対応する評価ロジックがなければエラーにする。
といった仕組み。これにより、
- パターンのチェック漏れが検出できます。
- 「条件」や、「取り得る値」の追加が割と簡単。「取り得る値」を追加しても漏れなくテストできます。
- 評価ロジックを使い回すことで、同じ結果になる場合の評価が効率的に行えます。
使い方
例えば、複数の属性を持つクラスがあって、
class Animal { String type; int age; }
さらに次のような戻り値を返す仕様の「howl()」関数を持つとする。
↓typeの値/→ageの値 | 0 | 1 | 2 | 3 |
---|---|---|---|---|
kitten | "meow" | "meow" | "gao" | "gao" |
tiger | "gao" | "gao" | "gao" | "gao" |
lion | "gao" | "gao" | "gao" | "gao" |
ここでageの取り得る値が0〜3の4パターンで、typeの値がkitten,tiger,lionの3パターンだとすると、総当たりの場合4x3パターンのテストが必要になります。
このテストをユーティリティを使って書いてみます。
登場人物
まずは登場人物について。★が付いているインターフェイスの実装は、使う人が用意するものです。
クラス | 説明 |
---|---|
Series | 条件。上の例だと「type」と「age」の2つが該当します。これに「取り得る値(Series.State)」を登録していきます |
★Series.State | 「取り得る値」を示します。上の例だと、typeならがkitten,tiger,lionの3つ、ageなら0〜3の4つが該当します。 |
★Series.Assertion | 評価ロジックです。上の例だと、"gao"が返される場合と"meow"が返される場合があるのでそれぞれ二つ用意します。 |
★Series.Assertions | 評価ロジックの集合です。「パターンID」に対応する「評価ロジック」を返します。 |
テストケースを書く
- 1.Series.Stateの実装を書く。
- まずはSeries.Stateの実装を書きます。クラスは、条件ごとに用意しました。
- 実装が必要なのは「doTransit」「undoTransit」「getName」の3つです。
- doTransit: 状態を変更する処理を書きます。
- undoTransit: 変更した状態を元に戻す処理を書きます。
- getName: 「パターンID」の一部として使われる文字列を返します。「取り得る値」を識別できればOK。
/**「age」用のSeries.State*/ class AgeState implements Series.State<Animal> { final int age; int defaultAge; AgeState( int age ) { this.age = age; } public void doTransit ( Animal animal ) throws Exception { // 状態を変更する処理を書く。 this.defaultAge = animal.age; animal.age = this.age; } public void undoTransit ( Animal animal ) throws Exception { // 状態を元に戻す処理を書く animal.age = this.defaultAge; } public String getName () { // 状態を識別するためのIDを返すようにする return String.valueOf( this.age ); } } /**「type」用のSeries.State*/ class TypeState implements Series.State<Animal> { final String type; String defaultType; TypeState( String type ) { this.type = type; } public void doTransit ( Animal animal ) throws Exception { this.defaultType = animal.type; animal.type = this.type; } public void undoTransit ( Animal animal ) throws Exception { animal.type = this.defaultType; } public String getName () { return String.valueOf( this.type ); } }
- 2.SeriesにSeries.Stateを登録する。
- Seriesを作成して、1で作成したSeries.Stateを登録します。
// Series Series<Animal> series = new Series<Animal>(); // Seriesに取り得る値を登録 // 「type」の取り得る値 series.add( new TypeState( "kitten" ) ); series.add( new TypeState( "tiger" ) ); series.add( new TypeState( "lion" ) ); //「age」の取り得る値 series = series.next(); series.add( new AgeState( 0 ) ); series.add( new AgeState( 1 ) ); series.add( new AgeState( 2 ) ); series.add( new AgeState( 3 ) );
- 3.Series.Assertionを書く。
- 評価ロジックを書きます。
// 評価ロジック final Series.Assertion<Animal> assertResultIsMeow = new Series.Assertion<Animal>() { public void assertState ( Animal animal ) { // howl()の戻り値が"meow"であることを確認 assertEquals( animal.howl(), "meow" ); } }; final Series.Assertion<Animal> assertResultIsGao = new Series.Assertion<Animal>() { public void assertState ( Animal animal ) { // howl()の戻り値が"gao"であることを確認 assertEquals( animal.howl(), "gao" ); } };
- 4.Series.Assertionsを書く。
- パターンID(Series.State#getName()の値を"/"で区切った値)ごとに適切な評価ロジックを返すように実装します。
// Assertions Series.Assertions<Animal> assertions = new Series.Assertions<Animal>() { public Series.Assertion<Animal> get ( String id ) { // IDごとの評価ロジックを返すように実装。 if ( "/kitten/0".equals( id ) || "/kitten/1".equals( id )) { // typeが「kitten」でageが1以下の場合、meowが返されるはず。 return assertResultIsGao; } else { // そうでなければgaoが返されるはず。 return assertResultIsGao; } } };
- 5.テスト実行。
- Series#run()でテストを実行します。
- 内部で総当たりで状態が変更され、取り得るすべてのパターンが順に作成されます。
- このとき、「doTransit」や「undoTransit」が使われます。
- さらに、パターンごとに対応するSeries.Assertionが評価されます。
- ここで、Series.Assertionで期待と異なる箇所がある場合や、状態に対応するSeries.Assertionが見つからない場合、エラーになります。
// テスト実行。 series.run( new Animal(), assertions );
といった感じ。一応まとめて書いたものも載せておきます。
// テスト対象 class Animal { String type; int age; String howl() { if ( "kitten".equals( type ) && age <= 1) { return "meow"; } else { return "gao"; } } } /**「age」用のSeries.State*/ class AgeState implements Series.State<Animal> { final int age; int defaultAge; AgeState( int age ) { this.age = age; } public void doTransit ( Animal animal ) throws Exception { // 状態を変更する処理を書く。 this.defaultAge = animal.age; animal.age = this.age; } public void undoTransit ( Animal animal ) throws Exception { // 状態を元に戻す処理を書く animal.age = this.defaultAge; } public String getName () { // 状態を識別するためのIDを返すようにする return String.valueOf( this.age ); } } /**「type」用のSeries.State*/ class TypeState implements Series.State<Animal> { final String type; String defaultType; TypeState( String type ) { this.type = type; } public void doTransit ( Animal animal ) throws Exception { this.defaultType = animal.type; animal.type = this.type; } public void undoTransit ( Animal animal ) throws Exception { animal.type = this.defaultType; } public String getName () { return String.valueOf( this.type ); } } // Series Series<Animal> series = new Series<Animal>(); // Seriesにパターンを登録 // 「type」の取り得る値 series.add( new TypeState( "kitten" ) ); series.add( new TypeState( "tiger" ) ); series.add( new TypeState( "lion" ) ); //「age」の取り得る値 series = series.next(); series.add( new AgeState( 0 ) ); series.add( new AgeState( 1 ) ); series.add( new AgeState( 2 ) ); series.add( new AgeState( 3 ) ); // 評価ロジック final Series.Assertion<Animal> assertResultIsMeow = new Series.Assertion<Animal>() { public void assertState ( Animal animal ) { // howl()の戻り値が"meow"であることを確認 assertEquals( animal.howl(), "meow" ); } }; final Series.Assertion<Animal> assertResultIsGao = new Series.Assertion<Animal>() { public void assertState ( Animal animal ) { // howl()の戻り値が"gao"であることを確認 assertEquals( animal.howl(), "gao" ); } }; // Assertions Series.Assertions<Animal> assertions = new Series.Assertions<Animal>() { public Series.Assertion<Animal> get ( String id ) { // IDごとの評価ロジックを返すように実装。 if ( "/kitten/0".equals( id ) || "/kitten/1".equals( id )) { // typeが「kitten」でageが1以下の場合、meowが返されるはず。 return assertResultIsGao; } else { // そうでなければgaoが返されるはず。 return assertResultIsGao; } } }; // テスト実行。 series.run( new Animal(), assertions );
実装
Seriesの実装は次の通りです。
import java.util.ArrayList; import java.util.List; /** * 総当たりテストを効率的にもれなく行うためのユーティリティ。 * * @param <T> コンテキストの型 */ public final class Series<T> { private List<State<T>> states = new ArrayList<State<T>>(); private Series<T> next = null; private Series<T> prev = null; /**コンストラクタ*/ public Series(){}; /** * 状態を追加します。 * @param state 状態 */ public void add(State<T> state) { states.add( state ); } /** * 次のシリーズ。 * @return 次のシリーズ */ public Series<T> next() { this.next = new Series<T>(); this.next.prev = this; return this.next; } /** * ルートを取得する。 * @return ルート */ private Series getRoot() { return this.prev == null ? this : this.prev.getRoot(); } /** * テストを実行します。 * @param context コンテキスト * @param assertions 評価ロジックのコンテナ * @throws Exception テスト失敗と見なすべき例外が発生した場合。 */ public void run( T context, Assertions<T> assertions) throws Exception { getRoot()._run(context, "", assertions); } private void _run( T context, String path, Assertions<T> assertions) throws Exception { for ( State<T> s : this.states ) { try { // 状態を変更 s.doTransit( context ); String tmp = new StringBuilder(path) .append("/").append(s.getName()).toString(); __run( context, tmp, assertions); } finally { // 状態を元に戻す。 s.undoTransit( context ); } } } private void __run( T context, String path, Assertions<T> assertions) throws Exception { if (next != null && next.states.size() > 0) { // まだ次がある。 next._run(context, path, assertions); } else { // ない場合は状態を評価。 Assertion a = assertions.get(path); if (a == null) { throw new RuntimeException("assertion not found"); } try { a.assertState(context); } catch ( Throwable t ) { throw new RuntimeException("error path : " + path, t); } } } /** * 評価ロジックのコンテナ * @param <T> コンテキストの型 */ public static interface Assertions<T> { /** * 状態にマッチする評価オブジェクトを取得します。 * @param path オブジェクトの状態を表す文字列。 * @return 状態にマッチする評価オブジェクト */ Assertion<T> get(String path); } /** * 評価ロジック * @param <T> コンテキストの型 */ public static interface Assertion<T> { /** * 正しいか評価します。 * @param context コンテキスト */ void assertState(T context); } /** * 状態 * @param <T> コンテキストの型 */ public static interface State<T> { /** * 状態を遷移します。 * @param context コンテキスト * @throws Exception 状態遷移に失敗した場合。 */ void doTransit(T context) throws Exception ; /** * 状態を元に戻します。 * @param context コンテキスト * @throws Exception 状態遷移に失敗した場合。 */ void undoTransit(T context) throws Exception ; /** * 状態の識別名を取得します。 * @return 状態の識別名 */ String getName(); } }
あとがき
作ってみたけど実はあんまし使ってなかったり。作らないといけないクラス数が多くて面倒なんだよな。