魅惑的(Fascinating)なテスティングフレームワーク Spock

f:id:Naotsugu:20150411014232p:plain


Spock とは

  • Java と Groovy アプリケーションのテスティングと仕様フレームワーク
  • JUnit, jMock, RSpec, Groovy, Scala, Vulcans などにインスパイアされた
  • Groovy の DSL による可読性の高いテストコードが書ける
  • メジャー IDE との連携も十分
  • 強力なデータドリブンテストとモック機能を提供
  • 2015年に1.0リリース

Githubはこちら


build.gradle

最低限の Gradle ビルドスクリプトは以下のようになります。

apply plugin: "groovy"

repositories {
    mavenCentral()
}

dependencies {
    compile "org.codehaus.groovy:groovy-all:2.4.1"
    testCompile "org.spockframework:spock-core:1.0-groovy-2.4"
}

Hamcrest Matcher を利用する場合は以下の依存を追加します。

testCompile "org.hamcrest:hamcrest-core:1.3"

モックを使う場合は以下も追加します。

testRuntime "cglib:cglib-nodep:3.1"
testRuntime "org.objenesis:objenesis:2.1"


HelloSpock

最初に簡単なテスト仕様を見てみましょう。

src/test/groovy/HelloSpock.groovy

import spock.lang.*

class HelloSpock extends spock.lang.Specification {
    def "length of Spock's and his friends' names"() {
        expect:
        name.size() == length

        where:
        name     | length
        "Spock"  | 5
        "Kirk"   | 4
        "Scotty" | 7
    }
}  

expect でテストgreenの条件を記載し、where にて条件に与える変数を指定しています。

gradle test でテストを実行してみると

Condition not satisfied:

name.size() == length
|    |      |  |
|    6      |  7
Scotty      false

失敗しました。 Scotty の文字長は 6 文字なのでテスト条件の間違いです。 Spock は失敗したテストに対して、このような詳細なレポートを提供してくれます。


仕様(テストケース)の構造

JUnit における Test クラスは、Spock では Specification(仕様)と呼びます。

仕様を定義するために以下をインポート宣言に追加します。

import spock.lang.*

仕様は以下のように Specification を継承したクラスとして定義します。

class MyFirstSpecification extends Specification {
  // フィールド
  // fixture メソッド
  // feature メソッド
  // helper メソッド
}

仕様の中には、フィールド定義、fixture メソッド、feature メソッド、helper メソッド を書きます。

定義した仕様は Sputnik という JUnit runner を通してテストが実行されます。


フィールド

仕様や fixture で利用するオブジェクトを宣言して初期化するのに良い場所です。 例えば以下のように、単に任意の定義をフィールド値として書くだけです。

def obj = new ClassUnderSpecification()
def coll = new Collaborator()

ここでの定義はsetup()メソッドで変数を初期化するのと同じ意味となります。

feature メソッド間で共有したい場合は以下のようにします。

@Shared res = new VeryExpensiveResource()

これは後述する setupSpec() メソッドによる初期化と同じ意味となります。


fixture メソッド

feature メソッドで実行時の環境設定を行います。 JUnit での @Before/@After, @BeforeClass/@AfterClass に該当します。

メソッド 説明
def setup() {} 全ての feature メソッドの前に実行される
def cleanup() {} 全ての feature メソッドの後に実行される
def setupSpec() {} 最初の feature メソッドの前に実行される
def cleanupSpec() {} 最後の feature メソッドの後に実行される


feature メソッド

feature メソッドは以下の様になります。これは JUnit の @Test のテストメソッドに該当します。

ラベル付けされたブロック内に仕様の詳細を記述します。

def "pushing an element on the stack"() {
  setup:
    // ・・・
  when:
    // ・・・
  then:
    // ・・・
  expect:
      // ・・・
  cleanup:
    // ・・・
  where:
    // ・・・
}

ブロックは以下の6種あり、ラベルに応じたブロックを含めます。

ブロックには以下のような項目を含めることができます(各ブロックの具体例は後述します)。

ラベル 説明
setup feature で利用するオブジェクトなどの宣言と初期化を行うブロック。別名として given も同じ。
when then にて示される予想結果の誘因となる任意のコードを含めることができる。 when-thenで繰り返して書くことができる。
then when に応答する予想結果。条件、例外条件、相互作用、および変数の定義を含めることができる。
expect 予想結果を定義。条件、変数の定義を含めることができる。when-thenと比べ関数的な記述となる。
cleanup where ブロックに続けて定義し、feature メソッドのリソース解放などを書く。
where データドリブンな feature の条件を書く。常に feature メソッドの最後に置く

各ブロックには以下のようにドキュメントを含めることができます。

setup: "open a database connection"
// code goes here

and: "seed the customer table"
// code goes here

and: "seed the product table"
// code goes here

and ラベルで分けて書くこともできます。

振る舞い駆動のストーリーとして given-when-then フォーマットで書くこともできます。

given: "an empty bank account"
// ...

when: "the account is credited $10"
// ...

then: "the account's balance is $10"


helper メソッド

feature メソッドが大きくなる場合や重複をさけるためにヘルパメソッドを定義できます。

def "offered PC matches preferred configuration"() {
  when:
  def pc = shop.buyPc()

  then:
  matchesPreferredConfiguration(pc)
}

void matchesPreferredConfiguration(pc) {
  assert pc.vendor == "Sunny"
  assert pc.clockRate >= 2333
  assert pc.ram >= 4096
  assert pc.os == "Linux"
}

ヘルパメソッドは以下のように書くこともできます。

def matchesPreferredConfiguration(pc) {
  pc.vendor == "Sunny" && pc.clockRate >= 2333
    && pc.ram >= 4096 && pc.os == "Linux"
}

しかしこのようにするとテスト失敗時のレポートがわかりにくくなるため assert で記載することが推奨されています。


データドリブンなテスト

where による条件の指定は以下のようにテーブル形式(Data Tables)で書くことができます。

import spock.lang.*

@Unroll
class DataDrivenSpec extends Specification {
  def "minimum of #a and #b is #c"() {
    expect:
    Math.min(a, b) == c
    
    where:
    a | b || c
    3 | 7 || 3
    5 | 4 || 4
    9 | 9 || 9
  }
}

上記例では結果を || を利用していますが、| としても問題ありません。

変数が1つだけの場合でも| は省略できないため以下のように書く必要があります。

a | _
1 | _
3 | _


@Unroll

@Unroll は feature メソッド名に条件として使われた値を埋め込んだ形で外部に公開します。

指定しない場合は minimum of #a and #b is #c というメソッド名となりますが、指定することで以下のように条件で与えられた名前が識別できるようになります(メソッドに個別に@Unrollを付与することもできます)。

f:id:Naotsugu:20150411231037p:plain

プロパティアクセスや引数なしのメソッドコールを含めることもできます。

def "#person is #person.age years old"() { ... }
def "#person.name.toUpperCase()"() { ... }


data pipe

条件は以下のようにdata pipe(<<)で書くこともできます。

def "minimum of #a and #b is #c"() {
  expect:
  Math.min(a, b) == c

  where:
  a << [3, 5, 9]
  b << [7, 4, 9]
  c << [3, 4, 9]
}

こんな書き方や [a, b, c] << sql.rows("select a, b, c from maxdata") こんな書き方 [a, b, _, c] << sql.rows("select * from maxdata") もできます。

条件には当然、以下のようにオブジェクトを初期化して渡すことができます。

def "#person.name is a #sex.toLowerCase() person"() {
  expect:
  person.getSex() == sex
  
  where:
  person || sex
  new Person(name: "Fred") || "Male"
  new Person(name: "Wilma") || "Female"
}


Stack の仕様例

Stack の仕様の例を以下に示します。

import spock.lang.*

class StackWithThreeElementsSpec extends Specification {
    def stack = new Stack()
    def setup() {
        ["elem1", "elem2", "elem3"].each { stack.push(it) }
    }
    def "size"() {
        expect: stack.size() == 3
    }
    def "pop"() {
        expect:
        stack.pop() == "elem3"
        stack.pop() == "elem2"
        stack.pop() == "elem1"
        stack.size() == 0
    }
    def "peek"() {
        expect:
        stack.peek() == "elem3"
        stack.peek() == "elem3"
        stack.size() == 3
    }
    def "push"() {
        when:
        stack.push("elem4")
        then:
        stack.size() == 4
        stack.peek() == "elem4"
    }
}

フィールドで stack を初期化し、setup() にて stack の fixture を定義しています。

各 feature で用意した fixture に対して stack 操作の検証を行っています。

最後の push のような手続き的な操作は when-then 形式で記載しています。なお、when-then のペアは何度でも繰り返して記載することができます。

実行結果は以下のようになります。

f:id:Naotsugu:20150412213053p:plain


例外の検証

Stack が空の場合に pop 操作で例外となることを検証してみます。

class EmptyStackSpec extends Specification {
    def stack = new Stack()
    
    def "size"() {
        expect: stack.size() == 0
    }
    
    def "pop"() {
        when: stack.pop()
        then: 
        thrown(EmptyStackException)
        stack.empty
    }
}

thrown(EmptyStackException) にて例外が投げられることを検証できます。

例外の中身を調べたい場合は以下のようにします。

when:
stack.pop()

then:
def e = thrown(EmptyStackException)
e.cause == null

または以下のように取得することもできます。

when:
stack.pop()

then:
EmptyStackException e = thrown()
e.cause == null


例外とならないことの検証

HashMap はキーと値に null を許容します。

キーに null を与えた場合に例外とならないことを検証するには以下のようになります。

def "HashMap accepts null key"() {
  setup:
  def map = new HashMap()

  when:
  map.put(null, "elem")

  then:
  notThrown(NullPointerException)
}

notThrown() にて例外が投げられないという仕様を定義できます。


タイムアウトを適用する

タイムアウトをアノテーションで指定することができます。指定時間を超過する場合は失敗として扱われます。

@Timeout(value = 100, unit = TimeUnit.MILLISECONDS)
def "I better be quick" { ... }

クラス単位でSpecificationに指定することもできます。

@Timeout(10)
class TimedSpec extends Specification {
  def "10秒超過でテスト失敗"() { ... }
  def "同様に10秒超過でテスト失敗"() { ... }

  @Timeout(value = 250, unit = MILLISECONDS)
  def "250ms超過でテスト失敗"() { ... }
}

クラス単位で指定した場合も、個別の feature で定義を上書きできます。


Hamcrest matchers を使う

Hamcrest matchers を使うことで、より柔軟な条件の表現が可能となります。

import spock.lang.Specification
import static spock.util.matcher.HamcrestMatchers.closeTo

class HamcrestMatchersSpec extends Specification {
    def "comparing two decimal numbers"() {
        def myPi = 3.14
        expect:
        myPi closeTo(Math.PI, 0.01)
    }
}

<expected-value> <matcher> の形式で条件を書けばよいだけです。


リソースのクリーンナップを行う

feature で利用するリソースのクリーンナップは以下のように行います。

setup:
def file = new File("/some/path")
file.createNewFile()

// ...

cleanup:
file.delete()

AutoCleanup というアノテーションでライフタイムに応じた自動クリーンナップを行うこともできます。

@AutoCleanup
def foo

対象に @AutoCleanup を付与することで、デフォルトで close() メソッドが呼び出されます。

異なるメソッドでクリーンナップを行いたい場合には、単にアノテーションでメソッド名を指定します。

@AutoCleanup("dispose")
def foo

close処理に失敗した場合の例外を無視するには以下のように指定します。

@AutoCleanup(quiet = true)
def foo


@Shared で共有リソースを利用する

feature 間で共有するリソースは @Shared でフィールド定義することで共有して利用できます。

以下の例はデータベース接続を共有して利用する例となります。

import groovy.sql.Sql
import spock.lang.Shared
import spock.lang.Specification

class DatabaseDrivenSpec extends Specification {
    @Shared sql = Sql.newInstance("jdbc:h2:mem:", "org.h2.Driver")

    def setupSpec() {
        sql.execute("create table maxdata (id int primary key, a int, b int, c int)")
        sql.execute("insert into maxdata values (1, 3, 7, 7), (2, 5, 4, 5), (3, 9, 9, 9)")
    }

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


Specification に自然言語の名前をつける

Specification に対して自然言語で名前を適用する Title アノテーションが用意されています。

@Title("This is easy to read")
class ThisIsHarderToReadSpec extends Specification {
  ...
}

ヒアドキュメント形式で記載する場合には Narrative を使います。

@Narrative("""
As a user
I want foo
So that bar
""")
class GiveTheUserFooSpec() { ... }


MOPを適用する

spock.util.mop.Use アノテーションでオブジェクトの振舞いの変更を適用できます。

class ListExtensions {
  static avg(List list) { list.sum() / list.size() }
}

class MySpec extends Specification {
  @Use(listExtensions)
  def "can use avg() method"() {
    expect:
    [1, 2, 3].avg() == 2
  }
}

List に対して avg() の振舞いを適用しています。


feature の実行を制限する

いくつかのアノテーションが提供されており、これらを使うことで feature の無効化などの制御ができます。

アノテーション 説明
@Ignore 一時的な無効を設定。メソッドまたはクラス(仕様)に付与できる。
@IgnoreRest 指定したメソッド以外を無効
@IgnoreIf 指定した条件を満たす場合に無効化する
@Requires 指定する条件を満たす場合に有効化する
@Stepwise 失敗した以降の feature をスキップする


@Ignore

feature の無効化。

@Ignore
def "my feature"() { ... }

Specification 自体を無効化。

@Ignore
class MySpec extends Specification { ... }


@IgnoreRest
def "無効化"() { ... }

@IgnoreRest
def "実行される"() { ... }

def "これも無効化"() { ... }


@IgnoreIf

無効化する条件を指定できます。

@IgnoreIf({ System.getProperty("os.name").contains("windows") })
def "Windowsの場合は無効"() { ... }

クロージャ内では sys env os jvm というプロパティが使えるので上の例は以下のようにも書けます。

@IgnoreIf({ os.windows })
def "Windowsの場合は無効"() { ... }


@Requires

こちらは逆に有効化する条件を指定します。

@Requires({ os.windows })
def "Windowsの場合のみ実行"() { ... }


@Stepwise

失敗した以降の feature をスキップします。

@Stepwise
class StepwiseExtensionSpec extends Specification {
    def "step 1"() {
        expect: true
    }
    def "step 2"() {
        expect: false
    }
    def "step 3"() {
        expect: true
    }
}

step 2 が失敗となるため、step 3 がスキップされます。

f:id:Naotsugu:20150412001811p:plain

次回は Spock の提供する Mocking API について見ていきます。 blog1.mammb.com



Groovyイン・アクション

Groovyイン・アクション

Spock: Up and Running: Writing Expressive Tests in Java and Groovy

Spock: Up and Running: Writing Expressive Tests in Java and Groovy

  • 作者:Rob Fletcher
  • 出版社/メーカー: O'Reilly Media
  • 発売日: 2017/05/27
  • メディア: ペーパーバック