🐕

Spock を使って Unit テストを書いてみる

に公開

はじめに

Java のテストというと JUnit を使うことが多いかと思いますが、今回は Spock というテストフレームワークを使ってみて、とても便利だったので紹介します。
Spock は Groovy ベースのテストフレームワークで、特にBehavior Driven Development(BDD)スタイルのテストを書くのに適しています。
JUnit と比べて、より表現力豊かなテストを書くことができ、読みやすさも向上します。
この記事では基本的な Spock の使い方、使う際の注意したいポイントをいくつか紹介します。

以下は公式のドキュメントです。詳細な情報は公式ドキュメントを参照してください。
https://spockframework.org/spock/docs/1.3/all_in_one.html

Spock の使い方

1. build.gradle の設定

今回は Gradle を使って Spock のテストを実行する方法を紹介します。まずは build.gradle に必要な依存関係を追加します。

build.gradle の例
plugins {
  id 'java'
  id 'groovy' // <-- Groovy プラグインを追加
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
  mavenCentral()
}

dependencies {
  testImplementation "org.spockframework:spock-core:2.4-M1-groovy-4.0" // <-- Spock の依存関係
  testImplementation 'org.apache.groovy:groovy-all:4.0.15' // <-- Groovy の依存関係  
  testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
  useJUnitPlatform()
  testLogging {
    events "started", "passed", "skipped", "failed" // テストの実行状況を表示
  }

}

2. テストクラスの作成

次に、テストクラスを作成します。Spock では Specification クラスを継承してテストを定義します。
以下は簡単なテストクラスの例です。Main クラスの add メソッドをテストしています。

MainSpec.groovy
package org.example

import spock.lang.Specification

class MainSpec extends Specification {

  Main main

  def setup() {
    main = new Main()
  }

  def "2つの数を渡すと足し算される"() {
    given: "1と2の数値を定義する"
    def a = 1
    def b = 2

    when: "足し算する"
    def result = main.add(a, b)

    then: "合計の3が返される"
    result == 3
  }
}

3. クラスの実装

テストクラスを作成したら、実際のクラスを実装します。テストクラスで定義した add メソッドを持つ Main クラスを以下のように実装します。

Main.java
package org.example;

public class Main {
  public static void main(String[] args) {
    System.out.println("Hello and welcome!");
    for (int i = 1; i <= 5; i++) {
      System.out.println("i = " + i);
    }
  }

  int add(int a, int b) {
    return a + b;
  }
}

4. テストの実行

それではテストを実行してみましょう。以下は成功した時です。

# ./gradlew test
Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details

> Task :test

MainSpec > 2つの数を渡すと足し算される STARTED

MainSpec > 2つの数を渡すと足し算される PASSED

BUILD SUCCESSFUL in 2s
3 actionable tasks: 1 executed, 2 up-to-date
IntelliJ IDEA での実行

result == 2 とテストを書き換えてわざと失敗させてみます。

# ./gradlew test

> Task :test FAILED

MainSpec > 2つの数を渡すと足し算される STARTED

MainSpec > 2つの数を渡すと足し算される FAILED #<------------- テストが失敗した時の出力
    org.spockframework.runtime.SpockComparisonFailure at MainSpec.groovy:20

1 test completed, 1 failed

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///Users/tsubasa.a.nomura/git/github.com/tsubasaxZZZ/learn-java/build/reports/tests/test/index.html

* Try:
> Run with --scan to get full insights.

BUILD FAILED in 844ms
3 actionable tasks: 1 executed, 2 up-to-date

テストの書き方のポイント

Spock を使っていていくつか便利な書き方や気づいた点があったので紹介します。

テストでロジックを書かない

Spock は Groovy をベースにしているため、テストの中でロジックを書くことができます。

例えば以下のようなテストはexpectで条件分岐を使ってしまい、期待する結果が分かりにくくなっています。

class ConditionalTestSpec extends Specification {
  def userService = new UserService()

  def "ユーザータイプによる権限チェック"() {
    given: "異なるタイプのユーザー"
    def users = [
            new User("管理者", Type.ADMIN),
            new User("一般", Type.USER),
            new User("ゲスト", Type.GUEST)
    ]

    expect: "タイプに応じた権限を持つ"
    users.each { user ->
      if (user.type == Type.ADMIN) {
        assert userService.canDelete(user) == true
        assert userService.canEdit(user) == true
        assert userService.canView(user) == true
      } else if (user.type == Type.USER) {
        assert userService.canDelete(user) == false
        assert userService.canEdit(user) == true
        assert userService.canView(user) == true
      } else if (user.type == Type.GUEST) {
        assert userService.canDelete(user) == false
        assert userService.canEdit(user) == false
        assert userService.canView(user) == true
      }
    }
  }
}

期待値だけでなく、given でも同様に条件分岐が可能です。その場合はどのような条件でテストが実行されるのかが分かりにくくなります。

テストに条件が含まれる場合は別のテストに分割して、期待値を明確にすると見通しが良くなります。
もしくは、一つのテストで複数の期待値を確認する場合は、where ブロックを使ってパラメータ化テストにするとコード量を減らしつつ、期待値を明確にすることができます。

  def "ユーザータイプによる権限チェック2 #name"() {
    given: "異なるタイプのユーザー"
    def user = new User(name, type)

    expect: "タイプに応じた権限を持つ"
    userService.canDelete(user) == canDelete &&
            userService.canEdit(user) == canEdit &&
            userService.canView(user) == canView

    where:
    name     | type       || canDelete | canEdit | canView
    "管理者" | Type.ADMIN || true      | true    | true
    "一般"   | Type.USER  || false     | true    | true
    "ゲスト" | Type.GUEST || false     | false   | true
  }

expect と then を正しく使う

Spock では expectwhen-then の2つの方法を使ってテストの期待値を定義できます。
expect はテストの期待値を定義するために使用され、when-then はテストの実行後にその結果を検証するために使用されるべきですが、相互に書き換え可能ケースがあるので使い分けに悩むことがあります。
ドキュメントでは、expect は純粋に値の検証のみを行う場合に使用し、when-then は副作用のある処理を実行する場合に使用するというガイドラインが書かれています。つまり、expect では条件によって検証処理が変わることは避け、常に同じ検証を行うようにします。

例えば、以下のコードはデータベースに保存した値を取得して検証することが期待されていますが、保存に失敗するとその後の検証が実行されないケースや前の処理の結果によって状態が変わってしまうケースがあります。

expectの例
class DatabaseServiceSpec extends Specification {
    def database = new Database()
    
    def "expectで副作用のある操作"() {
        expect:
        // 1人目のユーザー保存:成功
        database.save(new User(1, "太郎")) == true
        
        // データの整合性チェック:成功
        database.findById(1).name == "太郎"
        
        // 2人目のユーザー保存:成功
        database.save(new User(2, "次郎")) == true
        
        // ここで予期しない失敗
        database.count() == 3  // 実際は2なので失敗、ここで停止
        
        // 以下のコードは実行されない
        database.save(new User(3, "三郎")) == true
        database.generateReport() != null
        database.cleanupTempData() == true
    }
}

このような場合は、when を使って副作用のある処理を実行し、その後にthenで期待値を検証するようにします。

when-thenの例
class DatabaseServiceSpec extends Specification {
    def database = new Database()
    
    def "when-then で副作用のある操作"() {
        when: "すべての操作を実行"
        def saved1 = database.save(new User(1, "太郎"))
        def saved2 = database.save(new User(2, "次郎"))
        def count = database.count()
        def saved3 = database.save(new User(3, "三郎"))
        def report = database.generateReport()
        def cleaned = database.cleanupTempData()
        
        then: "すべての操作が完了していることが前提にできる"
        saved1 == true
        saved2 == true
        count == 3  // 失敗するが、whenブロックは完了している
        saved3 == true
        report != null
        cleaned == true
    }
}

Mock と Spy を使い分ける

Spock では Mock と Spy を使って依存関係のあるオブジェクトをテストすることができます。

Mock は完全なダミーのオブジェクトで、実際のオブジェクトの実装を使用しません。仮にスタブを定義しない場合、デフォルト値(null、0、false など)を返します。
一方で Spy は実際のオブジェクトをラップし、部分的にモック化できるオブジェクトです。明示的にスタブ化したメソッド以外は、実際のオブジェクトのメソッドが呼ばれます。

つまり、実際のオブジェクトの一部のメソッドだけをモック化したい場合に Spy を使うことで、Mock では必要なコードを書かずにテストできます。

以下は、TaxCalculator というクラスで計算ロジックの calculateTotalTax メソッドをテストする例です。

Spy の例
class TaxCalculatorSpec extends Specification {
    
    def "計算ロジックのテスト"() {
        given: "calculateTotalTax が依存するメソッドをモック化"
        def taxCalculator = Spy(TaxCalculator) {
            getCurrentTaxRates() >> [
                standard: 0.10,
                reduced: 0.08,
                car: 0.15
            ]
        }
        
        when: "税額を計算"
        def items = [
            new Item("食品", 1000, "FOOD"),
            new Item("車", 50000, "CAR")
        ]
        def taxAmount = taxCalculator.calculateTotalTax(items)
        
        then: "税額が正しく計算される"
        taxAmount == 7580.0d
    }
}

仮に、Mock を使う場合は以下のようなコードになるかもしれません。ただ、このテストは実際の TaxCalculator のロジックをテストしていないため、テストの目的としては不十分です。

Mock の例
def "Mockを使ったTaxCalculatorの直接テスト"() {
    given: "TaxCalculatorのMock"
    def taxCalculator = Mock(TaxCalculator)
    
    // getCurrentTaxRatesの振る舞いを定義
    taxCalculator.getCurrentTaxRates() >> [
        standard: 0.10,
        reduced: 0.08,
        car: 0.15
    ]
    
    // calculateTotalTaxの振る舞いを定義
    taxCalculator.calculateTotalTax(_) >> { List<Item> items ->
        /*********************/
        /* 簡易的な計算ロジック */
        /*********************/
    }
    
    when: "税額を計算"
    def items = [
        new Item("食品", 1000, "FOOD"),
        new Item("車", 50000, "CAR")
    ]
    def taxAmount = taxCalculator.calculateTotalTax(items)
    
    then: "税額が正しく計算される"
    taxAmount == 7580.0d  // モックで定義した計算結果
}

Mockでテストする場合は、TaxCalculatorに依存するServiceクラスをテストする際に、TaxCalculatorをDIなどで外部から注入します。

一方でそもそもロジックを注入できるような設計にしておけば利用可能性も上がりますしテストも書きやすくなるので、 Mock と Spy は併用するのが適切な使い方かもしれません。

xUnit Test Patterns(xUTP)

これまで紹介してきた内容は、xUnit Test Patterns (xUTP) という書籍に書かれているテストのパターンにも含まれている内容です。
例えば、テストの中でロジックを書くべきではないというパターンは、Conditional Logic in Tests というパターンに該当します。

http://xunitpatterns.com/Conditional Test Logic.html

また、expect と then の正しい使い分けは Test Concerns Separately というパターンに該当します(以下のGoal: Separation of Concernsのあたりです)。

http://xunitpatterns.com/Goals of Test Automation.html

xUTP 以外にも、FIRSTの原則、Given-When-Thenのパターン、Arrange-Act-Assert(AAA)パターンなど、テストの書き方には多くのパターンがあり、これらを理解することでより良いテストを書くことができそうです。

まとめ

「テストの書き方のポイント」で書いた内容は、あくまで私のケースで使っていて疑問に思い調べてみた内容に基づきます。必ずしもすべてのケースに当てはまるわけではないと思うので、一例として参考にしていただければと思います。

Spock は他にもおもしろい機能がありそうなので今後も便利な使い方を見つけていきたいと思います。
最後までお読みいただきありがとうございました。

Rakuten Volunteers Tech Blog

Discussion