Help us understand the problem. What is going on with this article?

Spring×Spockのステレオタイプごとのテストまとめ

More than 1 year has passed since last update.

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オブジェクトに渡される引数の検証
  • テストに必要な最低限の値の出力

引数の検証

テスト対象内で呼ばれる処理に合わせ、具体的な引数を受け取る形式にしましょう!

テストの実態にあったOKな例
Mockito.when(xxxService.fetchOne(1L))

中間処理でのバグを見逃してしまう可能性があるため、任意の引数をとる形式は不使用にすべきです。

任意の引数を使ったNGな例
Mockito.when(xxxService.fetchOne(Mockito.any()))
                                 ~~~~~~~~~~~~~~

テストに必要な最低限の値の出力

使用されている変数やフィールドにのみ値を詰めたMock戻り値にしましょう!

OKな例
def entity = new Entity(age: 28)

Mockito.when(/* ... */).thenReturn(entity)

参照するフィールドの誤りなどのバグ検知ができなくなるため、テストで使用していないフィールドには値を詰めるべきではありません。

NGな例
// テスト対象のメソッドでは 実は`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) }
Mockを用意する場合
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を呼ぶ処理のみになるはずです。

GETリクエスト
when:
def actual = mockMvc.perform(get("/api/xxx/${id}"))
        .andExpect(status().isOk())
        .andReturn()
POSTリクエスト
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

Mockを用意する場合
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を使用します。

xxxRepositoryTest
/**
 * 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
  • 用意したテストデータに応じた検索キーと結果をマッピングする
hondaYoshitaka
このアカウントで発信する内容は個人検証に基づくものであり、現在所属する会社の公式見解を示すものではありません。
https://github.com/hondaYoshitaka
forcas
『FORCAS(フォーカス)』は、データ分析に基づいて成約確度の高いアカウントを予測し、マーケティングと営業のリソースをそのターゲットアカウントに集中する最新マーケティング手法「Account Based Marketing (ABM) 」の実践を強力にサポートするマーケティングプラットフォームを開発・提供してます。
https://www.forcas.com/overview/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした