Spockを使ったユニットテスト

アソビュー! Advent Calendar 2019 - Qiitaの23日目の記事になります。
はじめまして。
アソビュー!のサーバーサイドエンジニアの山野です。
弊社ではSpockを使用したユニットテストを行なっています。
今回はその実装方法について備忘録も兼ね、書きたいと思います。

Spockとは

SpockはGroovyで動作するJava・Groovyアプリケーション向けのテストフレームワークで、
以下のような特徴や機能があります。
・テストケース内がブロックで明確に分かれており、
 テストケース内にテーブル形式でテストデータを定義できるため、テストの内容や仕様が分かりやすい。
・テスト結果確認(アサーション)が簡潔に記述できる。
・Mockの仕組みが標準で用意されている。
http://spockframework.org/

基本のテストコード実装例

テストクラス内のメソッドとブロックは以下のような形で実装します。
・テストクラスはSpockのSpecificationクラスの継承が必要です。
・テストケース内のsetupgivenと宣言してもよく、
 expectブロックを使えばテスト対象の実行と結果確認一緒に行えます。
・またテーブル記述を使ったテストを行う場合は、whereブロックが必要です。(後述します)

class RecommendItemSpec extends Specification{

    def setupSpec() {
        //テストクラスで一回だけ実行される初期化処理
    }

    def setup() {
        //テストケースごとに実行される初期化処理
    }

    def "テストケース"() {
        setup:
        // テストケース内の初期化/前提条件の設定など

        when:
        // テスト対象の処理実行

        then:
        // テスト結果確認

        cleanup:
        // テストケース内の後処理

    }

    def cleanup(){
        // テストケース毎に実行される後処理
    }

    def cleanupSpec(){
        // テストクラスで一回だけ実行される後処理
    }
}

[実装の具体例]
商品の単価と個数を渡すとその合計金額を返却するメソッドのテストケースです。
テスト結果の確認をする際はassertXXXXのようなチェック用メソッドを呼ぶ必要はなく、
通常プログラムのように比較演算子で確認することができます。

def "合計金額計算_when_then版"() {
    setup:
        def charge = 1000
        def number = 5
        def amount = new Amount(charge, number)

    when:
        def result = amount.totalAmount()

    then:
        result == 5000
}
def "合計金額計算_expect版"() {
    setup:
    def charge = 1000
    def number = 5
    def amount = new Amount(charge, number)

    expect:
     // テスト対象実行とテスト結果確認を一緒に行う。
    amount.totalAmount() == 5000

}

テーブル記述(データテーブル)を使ったパラメタライズドテストの実装

・ユニットテストにおいて、一つのテスト対象メソッドに
 複数パターンのパラメータを指定して繰り返しテストを行うたいことはよくあることと思います。
・例えば年齢と性別の情報を受け取り、
 以下のように年代と性別の組み合わせでおすすめ商品を出力仕分ける処理を考えた場合、
 テストパターンとして年齢の境界値テストが必要のため、
 最低12パターンのテストケースが必要となります。

年代        性別     おすすめ商品
10・20代     男性     アイテムA
10・20代     女性     アイテムB
30・40代     男性     アイテムC
30・40代     女性     アイテムD
50・60代     男性     アイテムE
50・60代     女性     アイテムF

通常であれば12のテストケースを書く必要がありますが、
その場合テストコード行数が長くなり可読性が悪く、
また何度も同じようなテストケースを繰り返し書くと、
設定値や期待結果にミスが生まれやすくなると思います。

Spockではこのようなテストを実施する場合、
データテーブルと呼ばれるテーブル形式でテストデータを記述することで、
渡し繰り返しテストを実行できる機能があります。
このデータテーブルを使用する場合はwhereブロックが必要となります。

[実装の具体例]

@Unroll
def "おすすめ商品取得"() {
    setup:
    //テーブルの値(age, gender)をパラメータとしてセット
    def recommend = new RecommendItem(age, gender)

    when:
    def result = recommend.recommendItem()

    then:
    //テスト結果とテーブルの期待値(expected)を比較
    result == expected

    where:
    age | gender   || expected
    10  | 'male'   || 'item-A'
    29  | 'male'   || 'item-A'
    30  | 'male'   || 'item-C'
    49  | 'male'   || 'item-C'
    50  | 'male'   || 'item-E'
    69  | 'male'   || 'item-E'
    10  | 'female' || 'item-B'
    29  | 'female' || 'item-B'
    30  | 'female' || 'item-D'
    49  | 'female' || 'item-D'
    50  | 'female' || 'item-F'
    69  | 'female' || 'item-F'
}

テーブルの一行目(テーブルヘッダ)は各項目の変数名にあたり、
setupブロックやwhen/thenブロックで使用することができます。
テストを実行すると二行目以降のテストデータが順次この変数にセットされ、
テーブル行数分のテストを実行してくれます。
この実装例では、
agegenderの値がsetupブロックのRecommendItemクラスのパラメータとしてセットされ、
thenブロックでwhereブロックのexpected(期待値)の値とテストの結果を比較しています。

このデータテーブルを使用したテスト(パラメタライズドテスト)を行う際は
テストケースに@Unrollをつけるのを忘れないようにしてください。

@Unroll
def "おすすめ商品取得"() {

理由としては@Unrollをつけずにテストを実行すると
下記のように全パターンテストを行なった後にテスト合否結果は返してくれるものの、
何回目のテストで失敗したかは表示をしてくれません。
f:id:ys-yamano:20191220005146p:plain

@Unrollをつけてテストを実行すると、
下記のように各回別のテストの合否が確認できるようになります。

f:id:ys-yamano:20191220005354p:plain

Mockを使ったテストコード実装

DB接続などが伴うユニットテストでDBのデータに依存しないよう
Mockを使うケースがあると思いますが、
Spockでは標準でMockの機能が用意されています。

以下は商品IDを指定してDBから商品情報を取得する処理で、
DBから商品情報を取得するDaoクラスをMock化した実装例です。

[テスト対象クラス]

public class ItemService {
    ItemDao dao;

    public Item find(int itemId) {
        return dao.select(itemId);
    }
}
public interface ItemDao {
    Item select(int itemId);
}
public class Item {
    int id;
    String itemName;

    public Item(int id, String itemName) {
        this.id = id;
        this.itemName = itemName;
    }
    public int id() {
        return id;
    }
    public String itemName() {
        return itemName;
    }
}

[テストコード例]

    def "Mockを使ったテスト"() {
        setup:
        // ItemDaoをMock化
        ItemDao mockDao = Mock()
        def service = new ItemService()

        // Mockオブジェクトにテストデータをセット
        mockDao.select(1) >> new Item(1, "商品1")
        // ItemServicedaoプロパティをMockオブジェクトで書き換え
        ItemService.metaClass.setAttribute(service, "dao", mockDao)

        when:
        def result = service.find(1)

        then:
        result.id() == 1
        result.itemName() == "商品1"
    }

ポイントとしてはモック化したいクラスに
SpockのMockingApi.Mock()メソッドをセットしモックオブジェクトを生成する点と、

ItemDao mockDao = Mock()
//または以下でもOK
def mockDao = Mock(ItemDao)

{メソッド} >> {テストデータ}の記述で
指定したデータを返却するよう振る舞いを定義している点です。

以下の場合ではselectメソッドのパラメータ値が1の場合のみ、
商品ID:1 商品名:商品1のデータが返却されるよう定義しています。

mockDao.select(1) >> new Item(1, "商品1")

以上、簡単ですがSpockの実装方法について書かせていただきました。
内容がご参考になれば幸いです。