Spock を使って Unit テストを書いてみる
はじめに
Java のテストというと JUnit を使うことが多いかと思いますが、今回は Spock というテストフレームワークを使ってみて、とても便利だったので紹介します。
Spock は Groovy ベースのテストフレームワークで、特にBehavior Driven Development(BDD)スタイルのテストを書くのに適しています。
JUnit と比べて、より表現力豊かなテストを書くことができ、読みやすさも向上します。
この記事では基本的な Spock の使い方、使う際の注意したいポイントをいくつか紹介します。
以下は公式のドキュメントです。詳細な情報は公式ドキュメントを参照してください。
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
メソッドをテストしています。
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
クラスを以下のように実装します。
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 では expect
と when-then
の2つの方法を使ってテストの期待値を定義できます。
expect
はテストの期待値を定義するために使用され、when-then
はテストの実行後にその結果を検証するために使用されるべきですが、相互に書き換え可能ケースがあるので使い分けに悩むことがあります。
ドキュメントでは、expect
は純粋に値の検証のみを行う場合に使用し、when-then
は副作用のある処理を実行する場合に使用するというガイドラインが書かれています。つまり、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
で期待値を検証するようにします。
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
メソッドをテストする例です。
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
のロジックをテストしていないため、テストの目的としては不十分です。
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
というパターンに該当します。
また、expect と then の正しい使い分けは Test Concerns Separately
というパターンに該当します(以下のGoal: Separation of Concerns
のあたりです)。
xUTP 以外にも、FIRSTの原則、Given-When-Thenのパターン、Arrange-Act-Assert(AAA)パターンなど、テストの書き方には多くのパターンがあり、これらを理解することでより良いテストを書くことができそうです。
まとめ
「テストの書き方のポイント」で書いた内容は、あくまで私のケースで使っていて疑問に思い調べてみた内容に基づきます。必ずしもすべてのケースに当てはまるわけではないと思うので、一例として参考にしていただければと思います。
Spock は他にもおもしろい機能がありそうなので今後も便利な使い方を見つけていきたいと思います。
最後までお読みいただきありがとうございました。
Discussion