読者です 読者をやめる 読者になる 読者になる
無料で使えるシステムトレードフレームワーク「Jiji」 をリリースしました!

・OANDA Trade APIを利用した、オープンソースのシステムトレードフレームワークです。
・自分だけの取引アルゴリズムで、誰でも、いますぐ、かんたんに、自動取引を開始できます。

総当たりテストをもれなく行う

複数条件の総当たりテストをもれなく行うためのユーティリティを書いてみました。

  • 「条件」ごとに「取り得る値」を登録しておき、
  • テスト実行で、「取り得る値」を順番に変えて、総当たりで取り得るすべてのパターンを生成。パターンごとに対応する評価ロジックを実行して結果を確認。
  • パターンに対応する評価ロジックがなければエラーにする。

といった仕組み。これにより、

  • パターンのチェック漏れが検出できます。
  • 「条件」や、「取り得る値」の追加が割と簡単。「取り得る値」を追加しても漏れなくテストできます。
  • 評価ロジックを使い回すことで、同じ結果になる場合の評価が効率的に行えます。

使い方

例えば、複数の属性を持つクラスがあって、

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

あとがき

作ってみたけど実はあんまし使ってなかったり。作らないといけないクラス数が多くて面倒なんだよな。