データ駆動テスト

入力値と期待する結果の組み合わせを検証するため、同じテストコードを複数回実行したいことがあります。Spockのデータ駆動テストは、これをサポートする最高の機能です。

イントロダクション

Math.maxメソッドの振る舞いを定義したいとしましょう。

class MathSpec extends Specification {
    def "maximum of two numbers"() {
        expect:
        // exercise math method for a few different inputs
        Math.max(1, 3) == 3
        Math.max(7, 4) == 7
        Math.max(0, 0) == 0
    }
}

この方法は、このような簡単な状況ではよい方法ですが、いくつかの問題点があります。

  • コードとデータが混在していて、簡単にどちらかを変更できない

  • データを簡単に自動生成したり、外部リソースを読み込んだりできない

  • 同じコードを複数回実行する場合は、コードを複製するか別メソッドに抽出をする必要がある

  • 実行が失敗した場合、失敗を引き起こした入力値がすぐに分からない

  • コードを複数回実行するために、実行するメソッドを分けるというやり方は、賢いやり方ではない

データ駆動テストのサポートは、この問題を解決します。はじめに上記のコードを、データ駆動のフィーチャメソッドへリファクタリングをしましょう。まず、ハードコーディングされている3つの整数値を、メソッドの引数(データ変数と呼びます)に置き換えます。

class MathSpec extends Specification {
    def "maximum of two numbers"(int a, int b, int c) {
        expect:
        Math.max(a, b) == c

        ...
    }
}

テストロジックの実装は完了ですが、入力値となるデータの指定が足りていません。これはメソッドの最後にくるwhere:ブロックで指定します。もっとも簡単(そして最も一般的)な方法は、where:ブロックでデータテーブルを使用する方法です。

データテーブル

データテーブルは、固定のデータセットと共に、フィーチャメソッドを実行する便利な方法です。

class Math extends Specification {
    def "maximum of two numbers"(int a, int b, int c) {
        expect:
        Math.max(a, b) == c

        where:
        a | b | c
        1 | 3 | 3
        7 | 4 | 4
        0 | 0 | 0
    }
}

テーブルの1行目はテーブルヘッダと呼ばれ、データ変数を定義します。この後に続く行はデータ行と呼ばれ、データ変数に対応する値を保持します。データ行は、行ごとにそれぞれ個別のフィーチャメソッドとして実行されます。これをメソッドのイテレーションと呼んでいます。もしイテレーションの途中で実行が失敗した場合は、そこで停止せず、最後までイテレーションが実行されます。最後に、失敗したすべてのイテレーションがレポートされます。

データテーブルには最低でも2つの列が必要です。もし列が1つのテーブルを定義したい場合は、以下のようにしてください。

where:
a | _
1 | _
7 | _
0 | _

イテレーション内での実行の分離

イテレーションはそれぞれ別々のフィーチャメソッドとして実行されます。各イテレーションはスペッククラスである自身のインスタンスを取得し、setupcleanupメソッドを、それぞれのイテレーションの実行前後に呼び出します。

イテレーション間のオブジェクトの共有

イテレーション間でオブジェクトを共有するには、@Shareまたはstaticフィールドで値を保持します。

注釈

where:ブロックからは@Shareとstaticフィールドの値のみアクセスが許可されています。

このような、@Shareやstaticフィールドの値は、他のフィーチャメソッドへも共有されることに注意してください。特定のフィーチャメソッド内に閉じて、イテレーション間でオブジェクトを共有する良い方法は、現在のところありません。もし、この問題をどうしても解決したい場合は、同じファイル内にあるフィーチャメソッドを、それぞれ別々のスペックファイルに分割してください。これは、わずかな重複コードのコストで、より良い分離を実現します。

シンタックスのバリエーション

さきほどのコードは、さらにいくつか改善ができます。まずはじめに、すべてのデータ変数はすでにwhere:ブロックで定義しているため、メソッドのパラメータを省略できます[1]。次に、入力と期待する出力を、視覚的に区別するために、論理和の記号(||)で区切れます。これを反映すると、コードは次のようになるでしょう。

class DataDriven extends Specification {
     def "maximum of two numbers"() {
         expect:
         Math.max(a, b) == c

         where:
         a | b || c
         3 | 5 || 5
         7 | 0 || 7
         0 | 0 || 0
     }
 }

失敗のレポート

maxメソッドの実装に誤りがあり、とあるイテレーションの途中で失敗したとしましょう。

maximum of two numbers   FAILED

Condition not satisfied:

Math.max(a, b) == c
    |    |  |  |  |
    |    7  0  |  7
    42         false

何回目のイテレーションで失敗して、使用していたデータは何でしょうか? この例では、2回目のイテレーションで失敗したと把握することは難しくありません。しかし、これを把握するのが非常に困難、または不可能である場合があります[2]。いずれにせよ、失敗をレポートするだけでなく、どのイテレーションで失敗したのか明瞭になると良いでしょう。これが@Unrollアノテーションの目的です。

メソッドのUnroll

@Unrollが付与されたメソッドは、イテレーションごとに独立した結果をレポートします。

@Unroll
def "maximum of two numbers"() { ... }

@Unrollはメソッドの実行には影響を与えません。影響があるのはレポート結果だけです。実行環境によりますが、結果の出力は次のようになるでしょう。

maximum of two numbers[0]   PASSED
maximum of two numbers[1]   FAILED

Math.max(a, b) == c
    |    |  |  |  |
    |    7  0  |  7
    42         false

maximum of two numbers[2]   PASSED

これは2回目のイテレーション(インデックスは1)が失敗したことを表しています。さらに、ちょっと手を加えることで、より見やすくできます。

@Unroll
def "maximum of #a and #b is #c"() { ... }

データ変数ab、そしてcを参照するために、データ変数の先頭にハッシュ(#)を付与することで、メソッド名でプレースホルダを使用できます。プレースホルダの出力は、以下のように実際に使用した値に置き換えられます。

maximum of 3 and 5 is 5   PASSED
maximum of 7 and 0 is 7   FAILED

Math.max(a, b) == c
    |    |  |  |  |
    |    7  0  |  7
    42         false

maximum of 0 and 0 is 0   PASSED

このようにすることで、maxメソッドが入力値70で失敗したことが一目瞭然になります。このプレースホルダの詳細はMore on Unrolled Method Namesを参照してください。

また、@Unrollアノテーションはスペッククラスにも付与できます。これはデータ駆動テストを行うフィーチャメソッドそれぞれにアノテーションを付与した場合と同じ効果が得られます。

データパイプ

データテーブルだけが、データ変数へデータを供給する唯一の方法ではありません。データテーブルは、実際には1つ、または複数のデータパイプのシンタックスシュガーです。

...
where:
a << [3, 7, 0]
b << [5, 0, 0]
c << [5, 7, 0]

データパイプは左シフト(<<)演算子を使用し、データ変数とデータプロバイダを接続します。データプロバイダはイテレーションごと1つ使用する、すべての値を保持します。データプロバイダには、Groovyでイテレーションが可能なオブジェクトであれば、どんなオブジェクトでも使用できます。これにはCollectionStringや、Iterableインタフェースを実装したオブジェクトが含まれます。データプロバイダは必ずしも、それがデータ(例えばCollection)である必要はありません。データプロバイダの値をテキストファイルや、データベース、スプレッドシート、またはランダムに生成したデータといった、外部リソースからデータを取得することもできます。また、データプロバイダは値が必要になった時点(次のイテレーション前)で、はじめて次の値を取得します。

データパイプで複数の値を扱う

もしデータプロバイダ(Groovyがイテレーション方法を知っているオブジェクト)がイテレーションごとに複数の値を返す場合は、複数のデータ変数へ同時に接続できます。シンタックスはGroovyのマルチ代入に似ていますが、左辺でパーレン(丸括弧)の代わりにブラケット(大括弧)を使用します。

@Shared sql = Sql.newInstance("jdbc:h2:mem:", "org.h2.Driver")

def "maximum of two numbers"() {
    ...
    where:
    [a, b, c] << sql.rows("select a, b, c from maxdata")
}

使用しないデータはアンダースコア(_)で無視できます。

...
where:
[a, b, _, c] << sql.rows("select * from maxdata")

データ変数への代入

データ変数へ直接、値を代入できます。

...
where:
a = 3
b = Math.random() * 100
c = a > b ? a : b

データ変数への代入はイテレーションごとに再評価されます。また、上記のように代入の右辺で他のデータ変数を参照できます。

...
where:
row << sql.rows("select * from maxdata")
// pick apart columns
a = row.a
b = row.b
c = row.c

データテーブル、データパイプ、代入の組み合わせ

必要に応じて、データテーブル、データパイプ、代入を組み合わせて使用できます。

...
where:
a | _
3 | _
7 | _
0 | _

b << [5, 0, 0]

c = a > b ? a : b

イテレーションの回数

イテレーションの回数は、使用可能なデータの量に依存しています。メソッドの実行時に、イテレーションの回数が異なる場合があります。このように、もしデータプロバイダが他のデータプロバイダよりも早く値が不足した場合は、例外が投げられます。ただし、代入はイテレーションの回数に影響を与えません。また、where:ブロックが代入だけの場合は、1回だけイテレーションが実行されます。

データプロバイダのクローズ

全てのイテレーションが完了した後に、データプロバイダが引数なしのcloseメソッドを持っている場合は、自動的にそのメソッドが呼び出されます。

Unroll時のメソッド名の詳細

Unroll時のメソッド名は、GroovyのGStringに似ていますが、以下の点が異なります。

  • 式は$[3]の代わりに#を使用し${...}に相当するシンタックスはない

  • 式はプロパティへのアクセスと引数なしのメソッド呼び出しのみサポート

nameageというプロパティを持つPersonクラスがあり、このPersonの型がpersonというデータ変数として参照が可能な場合、以下のように使用できます。

def "#person is #person.age years old"() { ... } // property access
def "#person.name.toUpperCase()"() { ... } // zero-arg method call

文字列以外の値(例えば上記の#person)は、Groovyの挙動に従いStringに変換されます。

次のメソッド名は正しくありません。

def "#person.name.split(' ')[1]" { ... } // cannot have method arguments
def "#person.age / 2" { ... } // cannot use operators

必要に応じて、より複雑な式を保持するために、データ変数を活用することもできます。

def "#lastName"() {
    ...
    where:
    person << ...
    lastName = person.name.split(' ')[1]
}

注記

[1]

メソッドの引数として宣言する理由として、よりIDEのサポートが得られやすことが上げられます。しかし、最近のIntellij IDEAではデータ変数を自動的に認識し、さらにデータテーブルに含まれている値からその型を推論します。

[2]

例えば、フィーチャメソッドはデータ変数をsetup:ブロックの中で使用できるなど、さまざまな条件で使用されます。

[3]

Groovyのシンタックスはメソッド名にドル記号を使用できません。