G*ワークショップZ May 2013 - Spockハンズオン

でしゃべってきました。

http://jggug.doorkeeper.jp/events/3872

さっそく@orangecloverさんがまとめてくれています。ありがとうございます!

http://d.hatena.ne.jp/orangeclover/20130518/1368845593

当日の資料はGitHubにおいてあります

モックの説明以降はずいぶん駆け足になって申し訳なかったのですが、それを見越してドキュメントと学習テストを充実させておいたので、それで勘弁して下さいw

ドキュメントは先程のGitHubのdocsディレクトリに、学習テストはsrc/test/groovyディレクトリにおいてあります。参加しなかった人でもなんとなく眺めればSpockがわかったような気になれる教材に仕上がっています。

また、当日@uehajから質問されてた「oldはどのように値を保持しているのか」という質問に対してコピーして持ってるのではないかと適当なことを言ってしまいましたが、裏で@kiy0takaさんが調べてくれていました。

ということでAST変換で先に評価して値を保持しているそうです。

ではでは、新運営委員長の@y_ugrails連携説明している暇ないからLTよろしくと無茶ぶりした@gantawitter、参加者のみなさんおつかれさまでした!

SpockでビルトインされているExtensionsとかそのへん

G* Advent Calendar 2012 12日目担当のyamkazuです。こんにちは。

今日はみんな大好きSpockでビルトインされている機能拡張について、いくつかピックアップして紹介します。機能拡張にカテゴライズされないものもあるかもしれませんが、その辺はゆるやかに。

また、この記事はSpock0.7を元に記述していますが、バージョンが変わるとアノテーションが存在しないとかありますので、新しいバージョンが出た場合はそのへんを注意してお読みください。

それではさっそく。

@Ignore

これは説明不要だと思いますが、Ignoreを付与すると指定したフィーチャの実行がスキップされます。アノテーションに理由を書くような使い方もできます。

@Ignore
def "xxx"() { expect: true }

@Ignore('hogehogeのため')
def "yyy"() { expect: true }

スペックに指定すると全体がスキップされます。

@Ignore
class IgnoreSpec extends Specification { ... }

@IgnoreRest

IgnoreRestはIgnoreとは対照的に、IgnoreRestが付与されたフィーチャのみを実行します。IDEを使っている場合は対象のフィーチャを決め打ちで実行するのは比較的容易なのですが、コンソールからtestを実行する場合する場合などはそうではありません。このアノテーションを使用すると実行したいフィーチャメソッドを簡単に指定することができます。

class IgnoreRestSpec extends Specification {
    def "xxx"() { ... }

    @IgnoreRest
    def "yyy"() { ... }

    @IgnoreRest
    def "zzz"() { ... }
}

上記のように複数指定することもでき、この例ではyyy、zzzのみが実行されます。

@IgnoreIf

IgnoreIfは指定されたクロージャの実行結果がtrueの場合にフィーチャの実行がスキップされます。クロージャの中では暗黙的に以下の変数が使用可能です。

  • env - System.getenv()のショートカット
  • properties - System.getProperties()のショートカット
  • javaVersion - Javaのバージョン

以下のように使用します。

@IgnoreIf({ true })
def "trueなので実行されない"() { expect: false }

@IgnoreIf({ false })
def "falseなので実行される"() { expect: true }

@IgnoreIf({ 1 < 2 })
def "1 < 2 はtrueなので実行されない"() { expect: false }

@IgnoreIf({ 1 > 2 })
def "1 > 2 はfalseなので実行される"() { expect: true }

@IgnoreIf({
    def a = 1
    def b = 1
    a + b == 2
})
def "closureをcallしているだけなので複数行書いても良い"() { expect: false }

@IgnoreIf({ javaVersion > 1.6 })
def "javaVersionでJVMのバージョンが参照できる"() { expect: false }

@IgnoreIf({ env["LANG"] != 'C' })
def "envがSystem.getenv()のショートカットになっている"() { expect: false }

@IgnoreIf({ properties["os.name"] == 'Mac OS X' })
def "propertiesがSystem.getProperties()のショートカットになっている"() { expect: false }

@FailsWith

Spockでは例外のテストを行う際は、以下のように

then:
MyException e = thrown()

thrown()を使用することができますが、FailsWithはいわゆるJUnit4の@Test(expected = MyException.class)のような記述の仕方を可能にするアノテーションで、指定した例外でフィーチャが失敗することを宣言できます。

@FailsWith(MyException)
def "xxx"() { expect: throw new MyException() }

@FailsWith(value = MyException, reason = "hogehogeのため")
def "yyy"() { expect: throw new MyException() }

スペックに付与することも可能です。

@FailsWith(MyException)
class FailWithSpec extends Specification { ... }

この場合は、スペック上のすべてのフィーチャが指定した例外で失敗することを宣言しています。

@Timeout

Timeoutはフィーチャの実行時間のタイムアウト値を指定することができます。このタイムアウト値を超過した場合はorg.spockframework.runtime.SpockTimeoutErrorがスローされます。

@Timeout(1)
def "1秒以内に終わる"() {
    expect: Thread.sleep 500
}

@FailsWith(SpockTimeoutError)
@Timeout(1)
def "1秒以内に終わらない"() {
    expect: Thread.sleep 1100
}

デフォルトでは単位は秒に設定されています。単位を変更したい場合はunit属性を指定します。

@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
def "500ミリ秒以内に終わる"() {
    expect: Thread.sleep 250
}

@FailsWith(SpockTimeoutError)
@Timeout(value = 250, unit = TimeUnit.MILLISECONDS)
def "500ミリ秒以内に終わらない"() {
    expect: Thread.sleep 300
}

@Unroll

通常Spockではwhereのパラメタライズテストを実行すると、そのフィーチャに対し1つの実行結果が出力されます。

def "x + y の合計を計算する"() {
    expect:
    x + y == sum

    where:
    x | y || sum
    1 | 2 || 3
    3 | 4 || 7
    5 | 6 || 11
}

実行結果

Test                    Duration    Result
x + y の合計を計算する  0.001s      passed

Unrollはこのパラメタライズテストをそれぞれの独立したフィーチャとして実行してくれます。また以下のように#でパラメータをフィーチャ名に埋め込むことができます。

@Unroll
def "#x + #y の合計は #sum になる"() { ... }

実行結果

Test                       Duration    Result
1 + 2 の合計は 3 になる    0s          passed
3 + 4 の合計は 7 になる    0s          passed
5 + 6 の合計は 11 になる   0s          passed

#形式での参照は引数なしのメソッドあれば、メソッドをチェインして参照することも可能です。詳細はリファレンスを参照してください。

@Shared

通常フィールドで宣言したフィクスチャはフィーチャの実行毎に初期化されます。

def counter = 0

def "counterをインクリメントする"() {
    expect:
    counter++ == expectedCounter

    where:
    expectedCounter << [0, 0, 0]
}

Sharedはフィーチャ間でフィクスチャを共有するSharedFixtureを実現してくれます。生成コストが高いオブジェクトをフィーチャ間で共有したい場合に便利です。

@Shared
def counter = 0

def "counterをインクリメントする"() {
    expect:
    counter++ == expectedCounter

    where:
    expectedCounter << [0, 1, 2]
}

@AutoCleanup

AutoCleanupはフィールドに設定することで自動で後処理をしてくれます。デフォルトではcloseメソッドが自動で呼びされます。

@AutoCleanup
def closeable = new Closeable()

明示的に呼び出す後処理のメソッドを指定することもできます。

@AutoCleanup("shutdown")
def shutdownable = new Shutdownable()

後処理の中に例外が発生した場合に発生した例外を握りつぶしたい場合はquiet属性にtrueを指定します。デフォルトはfalseです。

@AutoCleanup(value = 'shutdown', quiet = true)
def shutdownable = new Shutdownable()

これらの後処理はフィーチャ実行毎に実行されますが、@Sharedが付与されたフィールドでは全フィーチャの実行後に1度だけ実行されます。

@Stepwise

Stepwiseを使用すると一連のフィーチャをそれぞれ定義した順に実行してくれます。フィーチャが一連のシナリオとして実行されるイメージで、途中のテストが失敗すると以降のテストが実行されません。

@Stepwise
class StepwiseSpec extends Specification {
    def "first"() { expect: true }
    def "second"() { expect: false }
    def "third"() { expect: true }
}

上記の例ではfirst、second、thirdの順に実行されるはずですが、secondで失敗するため、thirdは実行されません。

おわりに

今回は紹介できませんが独自のアノテーションを定義して拡張するといったことも容易に出来るようになっています。きっと誰か書いてくれるはず。誰も書かなかったらそのうち書きます。

それでは明日は @irof さんです。

Spock0.7で追加されたStubについて

Spock0.7でStubを作る機能が追加されました。
http://docs.spockframework.org/en/latest/interaction_based_testing.html#stubs

Mockとの違いはデフォルトで返す値が違うとのこと。mockはnullを返しますがstubでは

といった感じ。どんな値を返すかはorg.spockframework.mock.EmptyOrDummyResponseで定義されている。すごく小さいクラスなので、ざっとみるだけでもどんな動作をするのかわかると思う。
あとorg.spockframework.smoke.mock.StubDefaultResponses.groovyというテストクラスがあるので、それをみるとわかりやすい。

このStubDefaultResponses.groovyをもとにmockとの動作の違いを確認してみた。

package org.yamkazu

import org.spockframework.mock.MockDetector
import spock.lang.Specification
import spock.lang.Unroll

class MockStubDiffSpec extends Specification {

    @Unroll
    def "#methodの値は、mockの場合は#mockExpected、stubの場合は#stubExpected"() {
        given:
        def mock = Mock(TestInterface)
        def stub = Stub(TestInterface)

        expect:
        mock."$method" == mockExpected
        stub."$method" == stubExpected

        where:
        method           | mockExpected | stubExpected
        'byte'           | 0            | 0
        'short'          | 0            | 0
        'int'            | 0            | 0
        'long'           | 0            | 0
        'float'          | 0            | 0
        'double'         | 0            | 0
        'boolean'        | false        | false
        'char'           | 0            | 0

        'byteWrapper'    | null         | 0
        'shortWrapper'   | null         | 0
        'intWrapper'     | null         | 0
        'longWrapper'    | null         | 0
        'floatWrapper'   | null         | 0
        'doubleWrapper'  | null         | 0
        'booleanWrapper' | null         | false
        'charWrapper'    | null         | 0

        'bigInteger'     | null         | BigInteger.ZERO
        'bigDecimal'     | null         | BigDecimal.ZERO

        'charSequence'   | null         | ""
        'string'         | null         | ""
        'GString'        | null         | ""

        'primitiveArray' | null         | [] as int[]
        'interfaceArray' | null         | [] as IPerson[]
        'classArray'     | null         | [] as Person[]

        'iterable'       | null         | []
        'collection'     | null         | []
        'queue'          | null         | []
        'list'           | null         | []
        'set'            | null         | [] as Set
        'map'            | null         | [:]
        'sortedSet'      | null         | [] as Set
        'sortedMap'      | null         | [:]
    }

    def "インタフェースが戻り値のメソッドの場合はそのインタフェースのstubを返す"() {
        given:
        def mock = Mock(TestInterface)
        def stub = Stub(TestInterface)

        expect: "mockの場合はnull"
        mock.unknownInterface == null

        and: "stubの場合はそのインタフェースのstubを返す"
        with(stub.unknownInterface) { // 0.7から出来るwithという書き方
            new MockDetector().isMock(it)
            name == ""
            age == 0
            children == []
        }
    }

    def "デフォルトコンストラクタがあるクラスが戻り値の場合はそのクラスのインスタンスを返す"() {
        given:
        def mock = Mock(TestInterface)
        def stub = Stub(TestInterface)

        expect: "mockの場合はnull"
        mock.unknownClassWithDefaultCtor == null

        and: "stubの場合はそのクラスの本物(モックではない)のインスタンスを返す"
        with(stub.unknownClassWithDefaultCtor) {
            !new MockDetector().isMock(it)
            name == "default"
            age == 0
            children == null
        }
    }

    // デフォルトコンストラクタがないクラスのstubを作るのに以下が必要となる。
    // "cglib:cglib-nodep:2.2.2"
    // "org.objenesis:objenesis:1.2"
    def "デフォルトコンストラクタがないクラスが戻り値の場合はそのクラスのstubを返す"() {
        given:
        def mock = Mock(TestInterface)
        def stub = Stub(TestInterface)

        expect: "mockの場合はnull"
        mock.unknownClassWithoutDefaultCtor == null

        and: "stubの場合はそのクラスのstubを返す"
        with(stub.unknownClassWithoutDefaultCtor) {
            new MockDetector().isMock(it)
            name == ""
            age == 0
            children == []
        }
    }

    static interface TestInterface {
        byte getByte()
        short getShort()
        int getInt()
        long getLong()
        float getFloat()
        double getDouble()
        boolean getBoolean()
        char getChar()

        Byte getByteWrapper()
        Short getShortWrapper()
        Integer getIntWrapper()
        Long getLongWrapper()
        Float getFloatWrapper()
        Double getDoubleWrapper()
        Boolean getBooleanWrapper()
        Character getCharWrapper()

        BigInteger getBigInteger()
        BigDecimal getBigDecimal()

        CharSequence getCharSequence()
        String getString()
        GString getGString()

        int[] getPrimitiveArray()
        IPerson[] getInterfaceArray()
        Person[] getClassArray()

        Iterable getIterable()
        Collection getCollection()
        Queue getQueue()
        List getList()
        Set getSet()
        Map getMap()
        SortedSet getSortedSet()
        SortedMap getSortedMap()

        IPerson getUnknownInterface()
        Person getUnknownClassWithDefaultCtor()
        ImmutablePerson getUnknownClassWithoutDefaultCtor()
    }

    static interface IPerson {
        String getName()
        int getAge()
        List<String> getChildren()
    }

    static class Person implements IPerson {
        String name = "default"
        int age
        List<String> children
    }

    static class ImmutablePerson extends Person {
        ImmutablePerson(String name, int age, List<String> children) {
            this.name = name
            this.age = age
            this.children = children
        }
    }
}

わかれば意外と素直な動作。

Spockを使ってGrailsのドメインクラスのConstraintsを華麗にテストする

Spockのwikiでも
https://code.google.com/p/grails-spock-examples/wiki/Overview#Testing_constraints
というのが紹介されているのですが、もうちょっとカッチョいい方法が
http://www.christianoestreich.com/2011/11/domain-constraints-grails-spock/
で紹介れていたのでやってみます。

まずSpockの準備
BuildConfig.groovyにspockを追加

    plugins {
        ...
        test ":spock:0.6"
        ...
    }

先のURLではConstraintUnitSpecという形で、Specificationを継承したクラスを作っているのですが、少し趣向を変えてPOJOで書いてみます。
場所は適当なところに。

class ConstraintsUnitTestMixin {

    void validate(obj, field, error) {
        assert error
        def validated = obj.validate()
        if (error == 'valid') {
            assert !obj.errors[field]
        } else {
            assert !validated
            assert obj.errors[field]
            assert error == obj.errors[field]
        }
    }
}

準備はこれで完了。ではではこんなドメインがあったとします。

class Person {
    String username
    Integer age
    static constraints = {
        username nullable: false, size: 4..20, unique: true
        age nullable: true, range: 0..200
    }
}

でconstraintsのテストはこんな感じになります。

import grails.plugin.spock.UnitSpec
import grails.test.mixin.TestFor
import grails.test.mixin.TestMixin
import org.apache.commons.lang.RandomStringUtils
import spock.lang.Unroll

@TestFor(Person)
@TestMixin([ConstraintsUnitTestMixin])
class PersonSpec extends UnitSpec {

    def setup() {
        mockForConstraintsTests(Person, [new Person(username: "yamkazu")])
    }

    @Unroll
    def "personの#fieldに#valを設定すると#errorとなる"() {
        when:
        def obj = new Person("$field": val)

        then:
        validate(obj, field, error)

        where:
        error          | field      | val
        'nullable'     | 'username' | null
        'size'         | 'username' | "abc"
        'valid'        | 'username' | "abcd"
        'valid'        | 'username' | RandomStringUtils.randomAlphabetic(20)
        'size'         | 'username' | RandomStringUtils.randomAlphabetic(21)
        'unique'       | 'username' | "yamkazu"
        'valid'        | 'age'      | null
        'range'        | 'age'      | -1
        'valid'        | 'age'      | 0
        'valid'        | 'age'      | 100
        'valid'        | 'age'      | 200
        'range'        | 'age'      | 201
    }

}

ちょっとだけ解説。
@TestMixinを使って継承しないでvalidateを読み込んでみました。今のところIDEの補完が効かなくなるのが難点ですが、、どうでしょうか。
@Unrollを使うとwhereで食わせる各パラメータがそれぞれ独立したテストとして扱われるみたいです。あと紹介したURLでは

@Unroll({"test person all constraints $field is $error"})

という形でクロージャを使っているのですが、このままだとうまく動きませんでした。今のところ現在の最新で試すと

@Unroll("test person all constraints #field is #error")

とするとうまくいきます。この記事の例でやったように単に@Unrollだけつけてメソッド名に文字列を指定してもOK。

ちなみに結果はこんなん。
f:id:yamkazu:20120519223859p:image

カッチョイイ!