SpringにおけるSpockユニットテストについて、ステレオタイプ(Controller/Service/Respository)ごとに方針をまとめます。また、基本的なSpock部品の使用方針についてもまとめます。
今回使用するサンプルコードはGitHubにもあがっていますので合わせて参照ください。
環境
カテゴリ | ライブラリ | version |
---|---|---|
Spring Testing | spring-boot-starter-test | 2.1.2.RELEASE |
テストフレームワーク | Spock | 1.2-groovy-2.5 |
Mock | Mockito | 2.23.4 |
DBテストデータ | DBUnit | 2.6.0 |
Spock Blocks
Spock Blocksの基本内容については公式ドキュメントBlocks|spockwebframeworkを参照ください。
Blockごとの方針
以下の4種類のブロックを積極的に使用し、可読性の高いコード心がけます。
given
- 以下の役割に該当する前処理をまとめて記載する
- パラメータの準備や加工
- Mockの設定
- Data Sourceへのデータ投入
when
- 常にシンプルに保つ
- テスト対象のみを記載することでテスト対象を明確にする
- この句では 絶対に前/後処理は書かない
then
- 検証項目には
assert
またはverifyAll
を付加する(後述) - シンプルな検証を心がける
- 複雑なロジックでの検証は避ける
- 複雑になりそうな場合、ケースを分割して実施することを検討する
where
- 入力と期待値の間に区切り
||
を付加する
param1 | param2 || expected
'1' |'2' || '期待値'
- 期待値を
then
句にハードコードする場合、この句に期待値がないことを示すため|| _
と書いておくとよい
param1 | param2 || _
'1' | '2' || _
アサーション機能の活用
単項目の検証 assert
単項目の検証には assert
を付加することを推奨します。
というのも、Spockではネストされた項目の検証は検証対象としてみなされません。
then:
actual.code == expectedCode // ここは検証項目と見なされる
for (def model : actual.subModels) {
model.name == expectedName // !! ここは検証項目と見なされない !!
}
「本テストでこの項目を検証したい」という意思を残すために付加することを推奨します。
then:
assert actual.code == expectedCode
for (def model : actual.subModels) {
assert model.name == expectedName
}
オブジェクト内の複数項目の検証 verifyAll
同一オブジェクト内の複数項目の検証にはSpockの機能であるverifyAll
を活用します。
verifyAll
はオブジェクト内の検証をまとめて行うための機能です。
検証したいオブジェクトを verifyAll
の引数として渡します。
verifyAll
内では引数のオブジェクトはコンテキスト it
として扱うことができます(it
の省略も可)。
then:
verifyAll(actual) {
it.id == 999L
it.name == 'name 1'
it.price == 777
}
オブジェクトでの複数項目検証では有用なため活用しましょう。
Mock
責務
モックオブジェクトはテスト対象がコード内部で使用している呼び出し先と同じインターフェイスを持ち外からは正しく動くように見えるが、内部ロジックは実装されておらず引数の検証とテストに必要な最低限の出力のみを行う。モックオブジェクトが未実装のクラスの振る舞いをまねることで、それに依存するクラス(開発中のクラス)の振る舞いを検証することができる。 ー モックオブジェクト | IT media より
以下2つの責務に注目して、テストでのMockの書き方・方針を整理してみます。
- Mockオブジェクトに渡される引数の検証
- テストに必要な最低限の値の出力
引数の検証
テスト対象内で呼ばれる処理に合わせ、具体的な引数を受け取る形式にしましょう!
Mockito.when(xxxService.fetchOne(1L))
中間処理でのバグを見逃してしまう可能性があるため、任意の引数をとる形式は不使用にすべきです。
Mockito.when(xxxService.fetchOne(Mockito.any()))
~~~~~~~~~~~~~~
テストに必要な最低限の値の出力
使用されている変数やフィールドにのみ値を詰めたMock戻り値にしましょう!
def entity = new Entity(age: 28)
Mockito.when(/* ... */).thenReturn(entity)
参照するフィールドの誤りなどのバグ検知ができなくなるため、テストで使用していないフィールドには値を詰めるべきではありません。
// テスト対象のメソッドでは 実は`id`/`name`フィールドは参照されていない!
def entity = new Entity(id: 1L, name: 'name 1', age: 28)
Mockito.when(/* ... */).thenReturn(entity)
Mockのインジェクション
@InjectMocks
XXXService service
@Mock
XXXRepository xxxRepository
@Mock
YYYRepository yyyRepository
-
@InjectMocks
- Mockを差し込む対象のクラスを指定する
- テスト対象クラスを指定する
-
@Mock
- Mockにするクラスを指定する
テスト全体のセットアップ処理で忘れずにモックを初期化します。
@Unroll
class XXXServiceTest extends Specification {
//...
def setup() {
MockitoAnnotations.initMocks(this)
}
TIPS: 引数の検証に関する諸注意
モック対象メソッドの引数クラス型に equals
メソッドが実装されていないケースでは、引数の同一性がとれないためモックが期待通り動作しません。
def 'sample test'() {
//...
Mockito.when(xxxRepository.findBy(new SearchKeys(1L)).thenReturn(/* ... */)
~~~~~~~~~~~~~~~~~~~~
}
/**
* `equals`未実装 (`Object#equals`が呼ばれる)
*/
// getter, setter and required-constructor
public static class SearchKeys {
private final Long id;
}
私がテストを書いていてequals
がなくてよく困ってたケースはMyBatis3のRowBounds
でした。
Mockito.when(xxxRepository.findAll(new RowBounds(0, 10))
~~~~~~~~~~~~~~~~~~~~
このようなケースでは引数の検証部分を独自で作って上げる必要があります。具体的にはorg.mockito.ArgumentMatcher
を実装したクラスを作成します。
私がサンプルとして作成したRowBoundsMatcherを使った場合、引数の検証部分を以下のように書くことで検証がうまくいくようになります。
Mockito.when(xxxRepository.findAll(argThat(RowBoundsMatcher.of(0, 10))))
ステレオタイプごとのテスト
Springで用意されているステレオタイプごとのテストの書き方を紹介します。
■ Controller
Springのテストコンポーネント MockMvc
を利用し、実際にリクエストしてテストを行います。
✔ 正常系
前処理 given
ここで出てくる処理は大まかに2種類に分類されると思われます。
given:
def params = new LinkedMultiValueMap<String, String>()
categoryId?.with { params.add('categoryId', it) }
price?.with { params.add('price', it) }
given:
def responseDto = XXXFetchResponseDto.builder().id(999L).name('xxx name').price(888).build()
Mockito.when(xxxService.fetchOne(id)).thenReturn(responseDto)
- リクエストのPath Variable・Query Parameter・Bodyを用意する
- ビジネス層のモックを用意する
テスト内容 when
シンプルに、かつ、テスト対象を明確にするため、MockMvc
を呼ぶ処理のみになるはずです。
when:
def actual = mockMvc.perform(get("/api/xxx/${id}"))
.andExpect(status().isOk())
.andReturn()
when:
def actual = mockMvc.perform(post("/api/xxx")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(objectMapper.writeValueAsBytes(params)))
.andExpect(status().isCreated())
.andReturn()
- HTTPステータスの検証を行う
- 正常系なので
2xx
/3xx
の検証になるハズ
- 正常系なので
-
MockMvc
からresponseを受け取る
検証 then
then:
def contents = objectMapper.readTree(actual.response.contentAsByteArray)
verifyAll(contents) {
it.path('id').asLong() == 999
it.path('name').asText() == 'xxx name'
it.path('price').asInt() == 888
}
- responseからコンテンツ部分だけを抜き出し、Controllerの戻り値を検証する
- 各項目が想定の値であること
- サンプルは期待値をたまたまハードコードしているが、
where
句に記載するでも良い
テストパターン where
where:
id || _
0L || _
- テストパターンがあれば追加する
✔ 異常系 (バリデーションエラー)
前処理 given
(正常系と同様)
テスト内容 when
when:
def actual = mockMvc.perform(get("/api/xxx").params(params))
.andExpect(status().isBadRequest())
.andReturn()
- HTTPステータスの検証を行う
- バリデーションエラーなので
400 Bad Request
の検証
- バリデーションエラーなので
検証 then
then:
def contents = objectMapper.readTree(actual.response.contentAsByteArray)
def errors = contents.path('errors').asList()
assert errors.size() == 1
verifyAll(errors[0]) {
it.path('field').asText() == field
it.path('code').asText() == code
}
- プロジェクトのAPIエラーパターンに応じたフィールドの検証をする
- サンプルではエラーフィールドとコード
テストパターン where
where:
categoryId | price || field | code
null | '2' || 'categoryId' | 'NotNull'
'' | '2' || 'categoryId' | 'NotNull'
'1.0' | '2' || 'categoryId' | 'typeMismatch'
'aaa' | '2' || 'categoryId' | 'typeMismatch'
'1' | '-2' || 'price' | 'Range'
'1' | '-1' || 'price' | 'Range'
'1' | '1000001' || 'price' | 'Range'
'1' | '1000002' || 'price' | 'Range'
'1' | '1.0' || 'price' | 'typeMismatch'
'1' | 'aaa' || 'price' | 'typeMismatch'
- エラーになるパターンを網羅的に挙げる
■ Service
Requestやデータベースへのアクセスを行っていない層のため、ステレオタイプの中では一番シンプルなテストになります。
前処理 given
given:
def entities = [
new XXX(id: 1L, name: 'name 1', price: 111),
new XXX(id: 2L, name: 'name 2', price: 222)
]
Mockito.when(xxxRepository.findAllByCategory(categoryId, price))
.thenReturn(entities)
テスト内容 when
when:
def actual = xxxService.fetchAll(categoryId, price)
検証 then
then:
assert actual.xxxs.size() == 2
verifyAll(actual.xxxs[0]) {
it.id == 1
it.name == 'name 1'
it.price == 111
}
verifyAll(actual.xxxs[1]) {
it.id == 2
it.name == 'name 2'
it.price == 222
}
テストパターン where
where:
categoryId | price || _
0L | 100 || _
■ Repository
Repository層のテストでは以下の方式で行います。
- 実際にDatabaseにテストデータを登録し、検索を行う
- テスト終了後はテストデータをRollbackする
- 読み込むテストデータは外部ファイルには置かず、テストメソッド内で完結させる
Databaseテストデータの準備
テストに合わせてDatabaseのレコードを用意するため、今回のサンプルではDBUnitを使用します。
予めデータを投入するメソッドを用意しておきます。
org.dbunit.DataSourceDatabaseTester
(tester
)クラスを使用してDatabaseに値を登録します。また、読み込みデータの表現にはDbspockを使用します。
/**
* databaseレコードをセットアップします.
* --------------------------------------------
* - データの形式は `com.yo1000.dbspock.dbunit` に準ずる
*
* @param data データセット
*/
private void prepareDataSet(final Closure data) {
tester.dataSet = DbspockLoaders.loadDataSet(data)
tester.onSetup()
}
このメソッドは前処理given
時に使用します。
前処理 given
given:
prepareDataSet({
xxx {
id | name | category_id | price | active
0L | 'name 0' | 0L | 0 | false
1L | 'name 1' | 10L | 100 | true
2L | 'name 2' | 20L | 200 | true
}
})
Dbspock形式のデータセットを用意します。
- テーブル名を指定する
- サンプルでは
xxx
- サンプルでは
- カラムを定義する
- サンプルでは
id | ... | active
- サンプルでは
- カラムに対応するレコードを用意する
テスト内容 when
when:
def actual = xxxRepository.findOne(key)
検証 then
then:
verifyAll(actual) {
it.id == _id
it.name == _name
it.categoryId == _categoryId
it.price == _price
it.active == _active
}
- 検索結果エンティティから対象のフィールドを検証する
テストパターン where
where:
key || _id | _name | _categoryId | _price | _active
0L || 0L | 'name 0' | 0L | 0 | false
1L || 1L | 'name 1' | 10L | 100 | true
- 用意したテストデータに応じた検索キーと結果をマッピングする