読者です 読者をやめる 読者になる 読者になる

uehaj's blog

Grな日々 - GroovyとかGrailsとかElmとかRustとかHaskellとかFregeとかJavaとか -

ペアワイズ法でSpockのテストデータを生成する

groovy spock JCUnit

(関連記事:http://uehaj.hatenablog.com/entry/2015/10/18/155107 )



JCUnitというすばらしいJUnitの拡張があります。

JCUnitではペアワイズ法もしくはオールペア法という手法でテストデータを生成します。ペアワイズ法というのは、試験対象コードに対するパラメタのバリエーションテストをする際に、より少ないテストデータの件数で、効率良くバグを発見できるというテストデータの選択技法だそうです。(オールペア法の記事)

折角の自動テストならば、テストデータも自動生成してしまおうと。しかし単純に総当たりだと、指数的にケース数が増えて手に負えなくなるので、統計学に基づいて、優れているとされている方法でデータを選択し実用的には十分なようにしよう、ということです。

JCUnitはJUnit4のRunner(@RunWithアノテーション)を使って実現されていますが、本記事では、そのエンジンだけを呼び出すことで、JCUnitをSpockのデータドリブンテストで使う方法を紹介します。

方法

Spockのテストケースで以下のように「genPairwiseTestData」を使用してデータを生成します。genPairwiseTestDataはJCUnitの機能を呼び出すためのラッパーとして機能するstaticメソッドで定義は後述のテストコード全体に含まれています。

    @Unroll
    def "分配法則(a=#a,b=#b,c=#c)"() {
    expect:
        c*(a+b) == c*a + c*b
    where:
        [a,b,c] << genPairwiseTestData(new Object(){ @FactorField public int a,b,c } )
    }

上記中、「[a,b,c] <<は」Spockのデータパイプという機能で、a,b,cというテストコードで使用する変数に対して、3要素のリストの集合を流し込むことで、パラメータのバリエーションに対してテストコードが複数回呼び出されるというものです。@Unrollはさらにそれをテストレポート上で複数のテストメソッドがあるかのように表示するアノテーションです。これらはいずれもSpockの機能に乗っとっていて、データパイプに流し込むデータをJCUnitの機能を使って生成することで、機能連携しています。

@FactorFieldはJCUnitが提供するアノテーションで、JCUnitで指定できる機能を利用できます*1
上記では、[a,b,c]がすべてintの場合ですが、型がもし違っていれば、

        [a,b,c] << genPairwiseTestData(new Object(){
            @FactorField public int a
            @FactorField public String a
            @FactorField public double c
       } )

のようにそれぞれ指定します。<<の左辺に表われる変数(上記ではa,b,c)は、右辺の@FactorFieldで同じ順序で一回ずつ指定される必要があります(冗長だが、現状の仕組みでは仕方がない)。

長いテストコード例

以下は単独で実行できるように@Grabで指定したSpockテストコードです。Grails中のSpockテストコードやGradleからの使用では適切な依存関係の指定で置き換えることになるでしょう。

groovy JCUnit.groovy

で実行できます。

@Grab('com.github.dakusui:jcunit:0.4.10')
@Grab('org.spockframework:spock-core:0.7-groovy-2.0')
import com.github.dakusui.jcunit.core.FactorField;
import com.github.dakusui.jcunit.generators.TupleGeneratorFactory;
import com.github.dakusui.jcunit.core.tuples.Tuple;
import com.github.dakusui.jcunit.generators.ipo2.IPO2;
import com.github.dakusui.jcunit.generators.ipo2.optimizers.GreedyIPO2Optimizer;
import com.github.dakusui.jcunit.constraint.constraintmanagers.ConstraintManagerBase;
import com.github.dakusui.jcunit.constraint.ConstraintManager;
import com.github.dakusui.jcunit.exceptions.UndefinedSymbol;
import spock.lang.*

class HelloSpec extends Specification {

    static ConstraintManager closureConstraintManager(List<String> names, Closure c) {
        return new ConstraintManagerBase() {
            @Override
            boolean check(Tuple tuple) throws UndefinedSymbol {
                Map map = [:]
                for (name in names) {
                    if (!tuple.containsKey(name)) {
                        throw new UndefinedSymbol();
                    }
                    map[name] = tuple.get(name)
                }
                c.delegate = map
                c.call()
            }
        }
    }

    static Collection genPairwiseTestData(Object object, Closure constraint = null) {
        def tg = TupleGeneratorFactory.INSTANCE.createTupleGeneratorForClass(object.class)
        def names = tg.factors.collect{it.name}
        def cm
        if (constraint == null) {
            cm = tg.constraintManager
        }
        else {
            cm = closureConstraintManager(names, constraint)
        }
        IPO2 ipo2 = new IPO2(tg.factors, 2, cm, new GreedyIPO2Optimizer())
        ipo2.ipo()
        return ipo2.result.collect{ testData -> names.collect{ testData[it] } }
    }


    @Unroll
    def "分配法則(a=#a,b=#b,c=#c)"() {
    expect:
        c*(a+b) == c*a + c*b
    where:
        [a,b,c] << genPairwiseTestData(new Object(){ @FactorField public int a,b,c } )
    }

    @Unroll
    def test1() {
    expect:
        c*(a+b) == c*a + c*b
    where:
        [a,b,c] << genPairwiseTestData(new Object(){ @FactorField public int a,b,c }, { a > 0 && b != c })
    }

    @Unroll
    def test2() {
    expect:
        (a+b).size() == a.size() + b.size()
    where:
        [a,b] << genPairwiseTestData(new Object(){ @FactorField public String a,b })
    }
    
    @Unroll
    def test3() {
    expect:
        a+b == b+a
    where:
        [a,b] << genPairwiseTestData(new Object(){
                                         @FactorField(intLevels=[1,2,3]) public int a
                                         @FactorField public int b
                                     })
    }
    
    @Unroll
    def test4() {
    expect:
        a == b || a != b
    where:
        [a,b] << genPairwiseTestData(new Object(){
                                         @FactorField public MyBoolean a
                                         @FactorField public MyBoolean b
                                     })
    }
}

enum MyBoolean {
    True,
    False
}

データの生成のされかた

3つのintデータは、デフォルトでは以下のデータがペアワイズ法で生成されます。1, 0, -1, 100, -100, Integer.MAX_VALUE, Integer.MIN_VALUEの7種類の値が、a,b,cのパラメータに流し込まれるので、全組み合せだと7^3=343ケースが実行されるところを、53テストケースに絞り込まれていますね。

[[a:1, b:1, c:0],
 [a:1, b:0, c:100],
 [a:1, b:-1, c:-1],
 [a:1, b:100, c:-100],
 [a:1, b:-100, c:2147483647],
 [a:1, b:2147483647, c:-2147483648],
 [a:1, b:-2147483648, c:1],
 [a:0, b:1, c:100],
 [a:0, b:0, c:2147483647],
 [a:0, b:-1, c:-100],
 [a:0, b:100, c:0],
 [a:0, b:-100, c:1],
 [a:0, b:2147483647, c:-1],
 [a:0, b:-2147483648, c:-2147483648],
 [a:-1, b:1, c:2147483647],
 [a:-1, b:0, c:-2147483648],
 [a:-1, b:-1, c:1],
 [a:-1, b:100, c:-1],
 [a:-1, b:-100, c:100],
 [a:-1, b:2147483647, c:-100],
 [a:-1, b:-2147483648, c:0],
 [a:100, b:1, c:-2147483648],
 [a:100, b:0, c:-1],
 [a:100, b:-1, c:0],
 [a:100, b:100, c:1],
 [a:100, b:-100, c:-100],
 [a:100, b:2147483647, c:2147483647],
 [a:100, b:-2147483648, c:100],
 [a:-100, b:1, c:1],
 [a:-100, b:0, c:0],
 [a:-100, b:-1, c:-2147483648],
 [a:-100, b:100, c:100],
 [a:-100, b:-100, c:-1],
 [a:-100, b:2147483647, c:100],
 [a:-100, b:-2147483648, c:-100],
 [a:2147483647, b:1, c:-100],
 [a:2147483647, b:0, c:1],
 [a:2147483647, b:-1, c:100],
 [a:2147483647, b:100, c:2147483647],
 [a:2147483647, b:-100, c:0],
 [a:2147483647, b:2147483647, c:-2147483648],
 [a:2147483647, b:-2147483648, c:-1],
 [a:-2147483648, b:1, c:-1],
 [a:-2147483648, b:0, c:-100],
 [a:-2147483648, b:-1, c:2147483647],
 [a:-2147483648, b:100, c:-2147483648],
 [a:-2147483648, b:-100, c:-2147483648],
 [a:-2147483648, b:2147483647, c:1],
 [a:-2147483648, b:-2147483648, c:2147483647],
 [a:-100, b:2147483647, c:2147483647],
 [a:-2147483648, b:1, c:0],
 [a:-2147483648, b:-100, c:100],
 [a:1, b:2147483647, c:0]]

他のデータ型について、デフォルトではどのようなデータの集合(レベル)をつかってデータが生成されるかはこちら

Enumは勝手に全要素を組合せてくれます。

データ集合の指定

元になるデータの集合(レベル)を指定することもできます。たとえば、intについて1,2,3の3通りのデータからの組合せを生成するには、以下のようにします。

@FactorField(intLevels=[1,2,3]) public int a

制約

データが満たすべき制約條件を与えて、テストケース数をふるいにかけて減らすこともできます。
以下では、変数間の関係がa > 0 && b != cを満たすテストケースに絞り込みます。

[a,b,c] << genPairwiseTestData(new Object(){ @FactorField public int a,b,c }, { a > 0 && b != c })

Groovyなので、クロージャで制約式を与えられるようにしてみました。

おまけ

生成されるテストデータは毎回かわるわけではないので、テストデータをファイルにキャッシュする仕組みがあると良い気もする。

まとめ

JCUnitすばらしい!Spock便利! Groovy超便利!!

追記(2014/10/27)

JCUnitの更新情報として作者の方からコメントを頂いています。記事中では使用していませんが、'levelsFactory'メソッドが0.4.11か0.4.12で'levelsProvider'に改名されていますので注意。

*1:ただし、githubのJCUnitのドキュメントは2014/10/13時点で、'com.github.dakusui:jcunit:0.4.10'で得られるjarと比べると、おそらく古い。本記事はソースコードを解析して書いています。 左記は勘違いでした。お詫びして訂正させて頂きます。古くありませんでした。たいへん申し訳ありません。