Edited at

JavaのユニットテストにSpockを適用する

More than 5 years have passed since last update.


Spockとは

Groovyで動作する、テスティングフレームワーク。

Javaコードのユニットテストというと、まず何はなくともJUnitということになる。

JUnitはJavaのユニットテストのデファクトスタンダードとして歴史も長く、優れたフレームワークではあるが、イマイチな点として


  • テスト失敗時に何がどうダメだったかが分かりにくい

  • パラメータ化テスト(Parameterized Test)を書くのがめんどくさい&コード見にくい

  • パラメータ化テストで失敗したときにどのデータで失敗したのかが分かりにくい

  • 標準でモックに対応しておらず、相互作用テストに別途モックライブラリを用意する必要がある

が挙げられる。同じぐらい有名なTestNGも似たり寄ったり。

Spockは下記の特徴をそなえている


  • PowerAssertによる強力なレポーティング (Groovy本体のPowerAssertともまた違うらしい)

  • DSLを使った簡潔で分かりやすい記述

  • 単純明快なデータドリブンテストの記述が可能

  • 標準でMockのAPIを提供

既存のJavaプロジェクトにユニットテストを導入したい人や、JUnitやTestNGでテスト書いてるけど、

もうちょっと楽に書けないかと思ってる人向けの記事です。


題材

題材にするのは、下記の4つのJavaクラスを使った仮想アプリケーション。

ソースコードは長くなるので末尾に記載します。

クラス
意味

Person
年齢と性別を属性に持つPOJO

PersonChecker
Personに対して男性かどうかを判定するisMeleメソッドや、大人かどうかを判定するisAdultメソッドを持つクラス

CapacityCounter
キャパシティの状態を持つカウンター

AttractionRoom
アトラクションルームクラス。ルームに人を追加するaddメソッドを持つ。キャパシティは合計20あり、男性の大人は3、女性の大人は2、子供は男女とも1を消費する。キャパシティがいっぱいになったら満員で、それ以上は人を追加できない


データ駆動テスト


まずはJUnitで書いてみる

PersonCheckerクラスのisAdultメソッドとisMaleメソッドをテストするためのコードをJUnitで書いてみる。


PersonCheckerTest.java

package spockexample;

import org.junit.Before;
import org.junit.experimental.runners.Enclosed;
import org.junit.experimental.theories.DataPoints;
import org.junit.experimental.theories.Theories;
import org.junit.experimental.theories.Theory;
import org.junit.runner.RunWith;

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;

@RunWith(Enclosed.class)
public class PersonCheckerTest {

@RunWith(Theories.class)
public static class isAdultTest {
static PersonChecker sut;

@Before
public void setup() {
sut = new PersonChecker();
}

/**
* パラメータ化テストのパラメータとなるFixture定義
*/

static class Fixture {
int age;
String sex;
boolean result;

Fixture(int age, String sex, boolean expected) {
this.age = age;
this.sex = sex;
this.result = expected;
}
}

/**
* テストに使用するパラメータを定義
*/

@DataPoints
public static Fixture[] fixtures = {
new Fixture(0, "m", false),
new Fixture(19, "m", false),
new Fixture(20, "m", true),
new Fixture(0, "f", false),
new Fixture(19, "f", false),
new Fixture(20, "f", true),
};

@Theory
public void testIsAdult(Fixture fixture) {
// Fixtureの値を使ってPersonオブジェクトを初期化
Person person = new Person(fixture.sex, fixture.age);

// テストメソッド実行&結果判定
assertThat(sut.isAdult(person), is(fixture.result));
}
}

@RunWith(Theories.class)
public static class isMaleTest {
static PersonChecker sut;

@Before
public void setup() {
sut = new PersonChecker();
}

/**
* パラメータ化テストのパラメータとなるFixture定義
*/

static class Fixture {
int age;
String sex;
boolean result;

Fixture(int age, String sex, boolean expected) {
this.age = age;
this.sex = sex;
this.result = expected;
}
}

/**
* テストに使用するパラメータを定義
*/

@DataPoints
public static Fixture[] fixtures = {
new Fixture(19, "m", true),
new Fixture(20, "m", true),
new Fixture(19, "f", false),
new Fixture(20, "f", false),
};

@Theory
public void testIsMale(Fixture fixture) {
// Fixtureの値を使ってPersonオブジェクトを初期化
Person person = new Person(fixture.sex, fixture.age);

// テストメソッド実行&結果判定
assertThat(sut.isMale(person), is(fixture.result));
}
}
}


パラメータのためのクラス定義が必要だったり、staticな内部クラスのオンパレードだったり。

なんかいろいろ辛い。

自分が知らないだけでもうちょっとエレガントな書き方があるのだろうか?


書いたJUnitテストを実行してみる

↑のコードはオールグリーンになるのだけど、わざとパラメータを変えて失敗させてみた場合、

こんな出力になる。

org.junit.experimental.theories.internal.ParameterizedAssertionError: testIsAdult(fixtures[4])

at org.junit.experimental.theories.Theories$TheoryAnchor.reportParameterizedError(Theories.java:192)
at org.junit.experimental.theories.Theories$TheoryAnchor$1$1.evaluate(Theories.java:146)
at org.junit.experimental.theories.Theories$TheoryAnchor.runWithCompleteAssignment(Theories.java:127)
at org.junit.experimental.theories.Theories$TheoryAnchor.runWithAssignment(Theories.java:111)
at org.junit.experimental.theories.Theories$TheoryAnchor.runWithIncompleteAssignment(Theories.java:120)
at org.junit.experimental.theories.Theories$TheoryAnchor.runWithAssignment(Theories.java:109)
at org.junit.experimental.theories.Theories$TheoryAnchor.evaluate(Theories.java:96)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:271)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:70)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:50)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.junit.runners.Suite.runChild(Suite.java:127)
at org.junit.runners.Suite.runChild(Suite.java:26)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.junit.runner.JUnitCore.run(JUnitCore.java:160)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:74)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:211)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:67)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:134)
Caused by: java.lang.AssertionError:
Expected: is <true>
but: was <false>
at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
at org.junit.Assert.assertThat(Assert.java:865)
at org.junit.Assert.assertThat(Assert.java:832)
at spockexample.PersonCheckerTest$isAdultTest.testIsAdult(PersonCheckerTest.java:59)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:47)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:44)
at org.junit.experimental.theories.Theories$TheoryAnchor$2.evaluate(Theories.java:175)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.junit.experimental.theories.Theories$TheoryAnchor$1$1.evaluate(Theories.java:141)
... 31 more

期待された結果と、実際の値の比較のみが

Expected: is <true>

but: was <false>

と出力されるだけで、実際パラメータ化されたどのデータで失敗したのかを知るには、

testIsAdult(fixtures[4])

を手がかりに、ソースコードから該当データを探す必要がある。


Spockで書いてみる

同じ目的のテストをSpockで書いてみる。


PersonCheckerSpec.groovy

package spockexample

import spock.lang.Specification
import spock.lang.Unroll;

class PersonCheckerSpec extends Specification {

@Unroll
def "#age歳で性別が#sexの場合に大人かどうかの判定で#resultが返る"() {
setup:
def sut = new PersonChecker()

expect:
sut.isAdult(new Person(sex, age)) == result

where:
age | sex || result
0 | "m" || false
19 | "m" || false
20 | "m" || true
0 | "f" || false
19 | "f" || false
20 | "f" || true
}

@Unroll
def "#age歳で性別が#sexの場合に、男性化どうかの判定で#resultが返る()"() {
setup:
def sut = new PersonChecker()

expect:
sut.isMale(new Person(sex, age)) == result

where:
age | sex || result
19 | "m" || true
20 | "m" || true
19 | "f" || false
20 | "f" || false
}
}


もはや説明不要なほど目的に対して明快なコードになっていることがお分かりいただけるだろう。


書いたSpockテストを実行してみる

こちらもわざとパラメータを変更して失敗させた結果を出力してみる。


実行結果

Condition not satisfied:

sut.isAdult(new Person(sex, age)) == result
| | | | | | |
| false | f 19 | true
| | false
| spockexample.Person@58c9fe14
spockexample.PersonChecker@6633e42f
<Click to see difference>

at spockexample.PersonCheckerSpec.#age歳で性別が#sexの場合に大人かどうかの判定で#resultが返る(PersonCheckerSpec.groovy:14)


SpockのPowerAssert機能で、どの変数、どの式にどんな値が入っているか、

なぜテストに失敗してしまったかが一目瞭然。素敵。

文字列同士の比較だと、一致率なんかも出してくれる。

def test() {

setup:
def strA = "Hello Spock!"
def strB = "Hello Java!"

expect:
strA == strB
}


実行結果

Condition not satisfied:

strA == strB
| | |
| | Hello Java!
| false
| 5 differences (58% similarity)
| Hello (Spock)!
| Hello (Java-)!
Hello Spock!



実行時のメソッド名を動的にわかりやすく

上の例でメソッド名のところに文字列を書いているが、メソッド名を文字列にし、パラメータを埋め込むことで、実行時にメソッド名が動的に評価され、結果をわかりやすく表示することができる。

例えば、Eclipseでこのテストを実行すると、下のような結果が表示される。

spockresult.png

ただし、メソッドに@Unrollアノテーションを付与している時にのみ有効。


失敗時の挙動

なお、サンプルとしては載せていないが、JUnitのパラメータ化テストは、途中で失敗した場合に

後続のパラメータに対してはテストが実行されない。

Spockは途中で失敗したものがあろうがなかろうが、定義されたすべてのパラメータに対してテストが実行される。

複数の失敗があった場合は、上記のレポートがそのそれぞれに出力される。


例外のテスト

例外が発生することを確認するためのテストもSpockなら簡潔に記述が可能

※以降メソッド部分のみ記載

    def "例外のテスト"() {

setup:
// テスト対象の初期化
def sut = new AttractionRoom()

when:
sut.add(null)

then:
// IllegalArgumentExceptionがスローされるはず
def ex = thrown(IllegalArgumentException)
// Exceptionのメッセージは「null is not acceptable.」のはず
ex.getMessage() == "null is not acceptable."
}

単にスローされる例外のクラスが判定出来ればよいということであれば、thrown(例外クラス)だけでよい。

それ以上のチェックをするなら、thrownメソッドが発生したThrowableを返すので、それをチェックすればよい。


モック化によるインタラクションテスト


呼び出し回数チェックによるテスト

メソッドを呼び出したことにより、内部でどのメソッドが何のパラメータで何回呼び出されたかを

テストする時の書き方。

    def "ポイント加算のメソッドが正しく呼ばれているか"() {

setup:
// テスト対象の初期化
def sut = new AttractionRoom()
def ageChecker = new PersonChecker()
// カウンターをモック化
CapacityCounter capacityCounter = Mock()
// こっちの書き方でも可
//def capacityCounter = Mock(CapacityCounter)

// Groovyではプロパティアクセスの簡略記法でsetterにアクセスできる
sut.personChecker = ageChecker
sut.capacityCounter = capacityCounter

when:
// 20歳女性の場合
def person1 = new Person("f", 20)
sut.add(person1)
then:
// 2ポイント加算されるメソッドが1度呼ばれたはず
1 * capacityCounter.addCount(2)

when:
// 19歳女性の場合
def person2 = new Person("f", 19)
sut.add(person2)
then:
// 1ポイント加算されるメソッドが1度呼ばれたはず
1 * capacityCounter.addCount(1)

when:
// 20歳男性の場合
def person3 = new Person("m", 20)
sut.add(person3)
then:
// 3ポイント加算されるメソッドが1度呼ばれたはず
1 * capacityCounter.addCount(3)

when:
// 19歳男性の場合
def person4 = new Person("m", 19)
sut.add(person4)
then:
// 1ポイント加算されるメソッドが1度呼ばれたはず
1 * capacityCounter.addCount(1)
}


パラメータ化を組み合わせる

当然ながら、パラメータ化して、データ駆動テストにもできる

    @Unroll

def "モックとデータ駆動テストの組み合わせ"() {
setup:
// テスト対象の初期化
def sut = new AttractionRoom()
def ageChecker = new PersonChecker()
// カウンターをモック化
CapacityCounter capacityCounter = Mock()
// こっちの書き方でも可
//def capacityCounter = Mock(CapacityCounter)

// Groovyではプロパティアクセスの簡略記法でsetterにアクセスできる
sut.personChecker = ageChecker
sut.capacityCounter = capacityCounter

when:
def person = new Person(sex, age)
sut.add(person)
then:
// addをパラメータとするaddCountがcalled回呼ばれたはず
called * capacityCounter.addCount(add)

where:
age | sex || add | called
20 | "m" || 3 | 1
20 | "f" || 2 | 1
19 | "m" || 1 | 1
19 | "f" || 1 | 1
}


スタブ

テスト対象のメソッドが依存する別のメソッドの呼び出しに対し、レスポンスを事前に宣言する。

    def "スタブによってモック化したオブジェクトの振る舞いを定義する"() {

setup:
def sut = new AttractionRoom()
def ageChecker = new PersonChecker()

// capacityCounter をモック化
def capacityCounter = Mock(CapacityCounter)
// getCount()が常に19を返すようスタブを宣言する
// 19を返す場合、子どもなら追加できるが、大人は追加できない
capacityCounter.getCount() >> 19

// Groovyではプロパティアクセスの簡略記法でsetterにアクセスできる
sut.personChecker = ageChecker
sut.capacityCounter = capacityCounter

when:
def person1 = new Person("m", 19)
sut.add(person1)

then:
1 * capacityCounter.addCount(1)

when:
def person2 = new Person("f", 20)
sut.add(person2)

then:
thrown(IllegalArgumentException)
}


スパイ

スパイを使うことで、実際のメソッドの振る舞いを残したまま、呼び出しを監視することができる。

    def "実際の処理を行いつつスパイで多重化テスト"() {

setup:
def sut = new AttractionRoom()
def ageChecker = new PersonChecker()
// カウンターをスパイとして作成
CapacityCounter capacityCounter = Spy(CapacityCounter)

// Groovyではプロパティアクセスの簡略記法でsetterにアクセスできる
sut.personChecker = ageChecker
sut.capacityCounter = capacityCounter

when:
// 20歳女性の場合
def person1 = new Person("f", 20)
// 19歳女性の場合
def person2 = new Person("f", 19)
// 20歳男性の場合
def person3 = new Person("m", 20)
// 19歳男性の場合
def person4 = new Person("m", 19)

sut.add(person1)
sut.add(person2)
sut.add(person3)
sut.add(person4)

then:
// スパイにより、メソッド呼び出しが監視できる
1 * capacityCounter.addCount(3)
1 * capacityCounter.addCount(2)
2 * capacityCounter.addCount(1)
// 0 * はthen:ブロックの最後に書くこと
0 * capacityCounter.reduceCount(_)

expect:
// モックではなくスパイを使うことで、本物の振る舞いも確認できる
capacityCounter.getCount() == 7
}


もう少し詳しく

Spockのテストクラスは、spock.lang.Specificationクラスを継承して作成する。

実際的なテストの中身を定義したメソッドをfeatureメソッドと呼ぶ。

つまり上記の例はすべてfeatureメソッド。

その他に、各featureメソッドに共通の前後処理を記述するためのメソッドがある。

メソッド
内容
JUnitで言うと

def setup()
各featureメソッドの前に実行される

@Beforeアノテーション

def cleanup()
各featureメソッドの後に実行される

@Afterアノテーション

def setupSpec()
最初のfeatureメソッドの前に1度だけ実行される

@BeforeClassアノテーション

def cleanupSpec()
最後のfeatureメソッドの後に1度だけ実行される

@AfterClassアノテーション


ブロック

featureメソッド内のブロック(setup:とかwhere:とか)は、下記のような種類がある。

ブロック
意味

setup: or given:
オブジェクトの生成、データの初期化などの準備。(givenはsetupのエイリアス)

when:
テスト対象を実行

then:
when実行後の結果の検証

expect:
テスト対象の実行と結果の検証を同時に

cleanup:
テストで作成されたデータの削除などの後始末処理

where:
データ駆動テストを書くときのパラメータ定義

whenとthenはセットで使う。

when -> then -> when -> then ...と繰り返すことはOK。

whenとthenの間にexpectを置くこともできない。(意味分かんないしね)


付録:テスト対象のJavaソースコード


Person.java

package spockexample;

/**
* 人を表すクラス。
* 年齢と性別を属性として持つ。
*/

public class Person {
/** 性別("m" or "f") */
private String sex;
/** 年齢 */
private int age;

public Person(String sex, int age) {
this.sex = sex;
this.age = age;
}

public String getSex() {
return sex;
}

public void setSex(String sex) {
this.sex = sex;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}



PersonChecker.java

package spockexample;

public class PersonChecker {
/**
* Personが大人(20歳以上)かどうかを返す
* @param person 判定する人
* @return 大人であればtrue、そうでなければfalse
*/

public boolean isAdult(Person person) {
return person.getAge() >= 20;
}

/**
* 男性かどうかを判定する
* @param person 判定する人
* @return 男性であればtrue、そうでなければfalse
*/

public boolean isMale(Person person) {
return "m".equals(person.getSex());
}
}



CapacityCounter.java

package spockexample;

/**
* キャパシティを数値で管理するクラス。
*/

public class CapacityCounter {
private int count = 0;

public CapacityCounter() {
}

/**
* 指定された数だけカウントを加算する
* @param count 加算するカウント
*/

public void addCount(int count) {
this.count += count;
}

/**
* 指定された数だけカウントを減算する
* @param count 減算するカウント
*/

public void reduceCount(int count) {
this.count -= count;
}

/**
* 現在のカウントを取得する
* @return 現在のカウント
*/

public int getCount() {
return this.count;
}
}



AttractionRoom.java

package spockexample;

/**
* アトラクションに人を載せたり降ろしたりするクラス。
* 載せる人の年齢や性別によって、アトラクションのキャパシティを計算していく
*/

public class AttractionRoom {
private PersonChecker personChecker;
public void setPersonChecker(PersonChecker personChecker) {
this.personChecker = personChecker;
}

private CapacityCounter capacityCounter;
public void setCapacityCounter(CapacityCounter capacityCounter) {
this.capacityCounter = capacityCounter;
}

private int capacityLimit = 20;
public void setCapacityLimit(int capacityLimit) {
this.capacityLimit = capacityLimit;
}

/**
* 指定された人をアトラクションに追加する。
* もし追加したらキャパシティオーバーになってしまう場合は、
* IllegalArgumentExceptionがスローされる
* @param person 追加する人
* @throws java.lang.IllegalArgumentException 追加したらキャパシティオーバーになってしまう場合
*/

public void add(Person person) throws IllegalArgumentException {
if (person == null) {
throw new IllegalArgumentException("nullは許可されていません");
}

int add = 0;
if (!personChecker.isAdult(person)) {
// 子どもは男女とも1
add = 1;
} else if (personChecker.isMale(person)) {
// 大人で男性の場合は3
add = 3;
} else {
// 大人で女性の場合は2
add = 2;
}

// この人を乗せるとキャパシティオーバーになる場合は例外をスロー
if (capacityCounter.getCount() > (capacityLimit - add)) {
throw new IllegalArgumentException("limit over");
}

capacityCounter.addCount(add);
}

/**
* 現在までのキャパシティのカウントを返します。
* @return 現在までのキャパシティカウント
*/

public int getCount() {
return this.capacityCounter.getCount();
}
}