データ駆動テスト

よくある状況で、入力値と結果のバリエーションを検証するために、同じテストコードを複数回実行したいことがあります。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
    }
}

このやり方は、簡単なケースでは非常によい方法ですが、いくつかの欠点もあります。

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

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

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

  • 実行が失敗した場合、失敗した時の入力値がすぐに分からない

  • 同じコードを複数回実行する場合は、実行をしているコードを別のメソッドに切り出さない限り、分離したメリットを得られない

Spockのデータ駆動テストのサポートはこの問題を解決します。はじめに上記のコードを、データ駆動テストの機能を利用した方法に、リファクタリングしてみましょう。まず、ハードコーディングされた3つのinteger値をメソッドの引数(データ変数と呼びます)に置き換えます。

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

非stringの値(例えば上記の#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のシンタックスはメソッド名にドル記号を使用できません。