Featured image of post SpringBoot2で「Spock」を使ってユニットテストを書く方法

SpringBoot2で「Spock」を使ってユニットテストを書く方法

SpringBoot2でJUnitに代わるテストフレームワーク「Spock」を使ってコントローラーのユニットテストを書く方法。依存関係の追加、テストクラスの作成と要点の解説、実行、HTMLのエラーレポートまで。TDD入門に♪

3477文字

Spockを使おう

時間がないからユニットテストを書かないは悪

皆さんはシステム開発をする際にユニットテストは書いていますでしょうか?

スケジュールがパツパツだから、ユニットテストは後回し!といった感じの案件も正直少なくないと思います。

しかし、ユニットテストがかかれないまま機能追加や機能改修が発生すると、結局ユニットテストを追加する余裕が出ずに、ユニットテストでカバー出来る部分テスターさんが自分たちでやらないといけなくなってしまったりと、長い目で見た際に工数が結果として上がってしまう可能性がとても高いです。

テスト駆動開発の流行

さらに近年は、テスト駆動開発(TDD)という言葉が有名になってきているので、なおさらユニットテストを妥協するのはオススメ出来ません。

テスト駆動開発 (てすとくどうかいはつ、test-driven development; TDD) とは、プログラム開発手法の一種で、プログラムに必要な各機能について、最初にテストを書き(これをテストファーストと言う)、そのテストが動作する必要最低限な実装をとりあえず行った後、コードを洗練させる、という短い工程を繰り返すスタイルである。多くのアジャイルソフトウェア開発手法、例えばエクストリーム・プログラミングにおいて強く推奨されている。近年はビヘイビア駆動開発へと発展を遂げている。テスト駆動開発 - Wikipedia

JUnitとSpock

では、ユニットテストを書く際に利用するのフレームワークは何を使えば良いのでしょうか。

Java開発のユニットテストといえば、JUnitが有名ですよね。

[adsense-responsive]

JUnitとはJavaで開発されたプログラムにおいてユニットテスト(単体テスト)の自動化を行うためのフレームワークである。JUnit - Wikipedia

最新バージョンはJUnit5であり、もちろんSpringBootもサポートしています。

そんなJUnit一強時代を崩すべく登場したのがSpockです。

Spock the enterprise ready specification frameworkSpock

Spockとは、Groovy言語で動作するテスティングフレームワークです。

こちらは、当時JUnitが弱かったパラメータ化テスト(Parameterized Test)や、実施結果が直感的に分かりづらい、などといったデメリットを補うべくして生まれました。

もちろん、SpringBootもSpockの利用を推奨しており、手厚いサポートをしているため導入しやすいです。
さらには日本語ドキュメントも用意されています。

最高かよ!!!

今回はプロジェクトへの導入方法から簡単なコントローラークラスのテスト方法までをご紹介しようと思います。

本来はtest用のprofileを用意したり、サービス層配下はStub化する必要がありますが、今回はその辺は触れないので次回以降に余裕があったら対応してみようと思います。

手順

依存関係の追加

まずは依存関係build.gradleに追加していきましょう。

devtools/build.gradle
buildscript {
  apply from: 'property.gradle'

...
()
...

    dependencyManagement {
    }

	dependencies {
		implementation "org.springframework.boot:spring-boot-starter:${accountService.springBootVersion}"
		implementation "org.springframework.boot:spring-boot-starter-data-jpa:${accountService.springBootVersion}"
		implementation "org.springframework.boot:spring-boot-starter-json:${accountService.springBootVersion}"

		implementation "ch.qos.logback.contrib:logback-json-classic:${accountService.logbackJsonVersion}"
		implementation "ch.qos.logback.contrib:logback-jackson:${accountService.logbackJsonVersion}"

		testImplementation "org.springframework.boot:spring-boot-starter-test:${accountService.springBootVersion}" // 追加
		testImplementation "org.spockframework:spock-core:${accountService.spockVersion}"// 追加
		testImplementation "org.spockframework:spock-spring:${accountService.spockVersion}"// 追加


		testCompileOnly "ch.qos.logback.contrib:logback-json-classic:${accountService.logbackJsonVersion}"
		testCompileOnly "ch.qos.logback.contrib:logback-jackson:${accountService.logbackJsonVersion}"

	}
    test {
        testLogging {
            events 'started', 'skipped', 'passed', 'failed'
        }
    }
}

property.gradleにもSpockのバージョンを追加します。

devtools/property.gradle
ext {
    accountService = [:]
    accountService.springBootVersion = '2.1.3.RELEASE'
    accountService.sourceCompatibility = 1.8
    accountService.group = 'tech.blogenist.service'
    accountService.version = '0.0.1-SNAPSHOT'

    accountService.lombokVersion = '1.18.6'
    accountService.flywayVersion = '5.1.3'
    accountService.postgresVersion = '42.2.5'
    accountService.snakeyamlVersion = '1.19'

    accountService.awsSdkVersion = '1.11.483'
    accountService.cloudWatchLogbackAppenderVersion = '1.11'
    accountService.logbackJsonVersion = '0.1.5'

    accountService.applicationYmlPath = "../api/src/main/resources/"
    accountService.spockVersion = "1.3-groovy-2.5" // 追加
}

テストクラスの作成

次にテストクラスを作成しましょう。

SpockはGroovy言語で作るので、 `src/test/groovy` 配下に作成する必要があります。
例の如く、テスト対象と同パッケージ階層にする必要があるので気をつけましょう。

今回はAccountDetailControllerのテストクラスを作成してみようと思います。

テストクラスには、Specificationクラスを継承させる必要があります。
また、クラス名はSpecで終わらせるのがルールとなっています。

AccountDetailControllerSpec
package tech.blogenist.service.account.api.controller.account.detail

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.web.context.WebApplicationContext

import groovy.json.JsonSlurper
import spock.lang.Specification
import spock.lang.Unroll
import tech.blogenist.service.account.api.infrastructure.rest.path.RestPathConst
import tech.blogenist.service.account.api.model.account.detail.response.AccountDetailResponseModel
import tech.blogenist.service.account.value.account.AccountDisplayNameValue
import tech.blogenist.service.account.value.account.AccountIdValue
import tech.blogenist.service.account.value.account.AccountInfomationIdValue
import tech.blogenist.service.account.value.account.AccountNameValue
import tech.blogenist.service.account.value.account.AgeValue
import tech.blogenist.service.account.value.common.datetime.MasterNameValue

@AutoConfigureMockMvc
@SpringBootTest
class AccountDetailControllerSpec extends Specification {

    @Autowired
    protected MockMvc mvc

    @Unroll
    def "【正常系】#CASE : #ACCOUNT_ID + #HTTP_STATUS"() {
        setup:
        def TARGET_PATH =
                RestPathConst.PATH_SEPARATOR + RestPathConst.VersionPath.V1 +
                RestPathConst.CategoryPath.AccountPath.BASE_PATH +
                ACCOUNT_ID + RestPathConst.PATH_SEPARATOR
        def parser = new JsonSlurper()

        when:
        def actual = mvc.perform(MockMvcRequestBuilders.get(TARGET_PATH))
                .andReturn()
                .getResponse()
        then:
        assertThat(actual.getStatus(), is(HTTP_STATUS))

        def json = parser.parseText(actual.getContentAsString())
        assertThat(json.accountId.value, is(RESPONSE_BODY.accountId.value))

        where:
        CASE    | ACCOUNT_ID || HTTP_STATUS | RESPONSE_BODY
        "対象のアカウントが存在する事" |10001|| 200 | AccountDetailResponseModel.builder()
                .accountId( AccountIdValue.of(10001) )
                .accountInfomationId( AccountInfomationIdValue.of(20001 ))
                .name( AccountNameValue.of("Test太郎" ))
                .age( AgeValue.of(20 ))
                .displayName( AccountDisplayNameValue.of("表示名" ))
                .sex(MasterNameValue.of( "男性" ))
                .build();

        "対象のアカウントが存在する事" |10002 || 200| AccountDetailResponseModel.builder()
                .accountId( AccountIdValue.of(10002) )
                .accountInfomationId( AccountInfomationIdValue.of(20001 ))
                .name( AccountNameValue.of("Test太郎" ))
                .age( AgeValue.of(20 ))
                .displayName( AccountDisplayNameValue.of("表示名" ))
                .sex(MasterNameValue.of( "男性" ))
                .build();
    }

    @Unroll
    def "【異常系】#CASE : #ACCOUNT_ID + #HTTP_STATUS"() {
        setup:
        def TARGET_PATH =
                RestPathConst.PATH_SEPARATOR + RestPathConst.VersionPath.V1 +
                RestPathConst.CategoryPath.AccountPath.BASE_PATH +
                ACCOUNT_ID + RestPathConst.PATH_SEPARATOR
        def parser = new JsonSlurper()

        when:
        def actual = mvc.perform(MockMvcRequestBuilders.get(TARGET_PATH))
                .andReturn()
                .getResponse()
        then:
        assertThat(actual.getStatus(), is(HTTP_STATUS))

        def json = parser.parseText(actual.getContentAsString())
        assertThat(json.code, is(CODE))
        assertThat(json.message, is(MESSAGE))
        assertThat(json.timestamp, is(not(null)))

        where:
        CASE    | ACCOUNT_ID || HTTP_STATUS | CODE | MESSAGE
        "対象のアカウントが存在しない事" |1 || 404| "404_00001" | "共通エラーメッセージ";
    }
}

要点の解説

@AutoConfigureMockMvc
[adsense-responsive]

サーバー起動せずに、MVCに関するテストを行えるようにするとても便利なアノテーション。

@SpringBootTest

SpringBootでユニットテストを行う際に、application.ymlを読み込んだりなどの準備をよしなにやってくれる魔法のアノテーション。

MockMvc

上記のアノテーションを追加することで、利用可能になるモッククラスです。

こちらを使うことで擬似的にリクエストを投げていると同等の動きを再現する事が可能になります。

@Unroll

後述するパラメタライズテスト時に、そのパラメーター単位でテスト結果をログやレポートに出力してくれます。

また、ケースとなる関数名にパラメタライズで指定した変数をセットする事が可能なので、レポートもとても見やすくなります。

実行ログ
// @Unrollがある場合
tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10001 + 200 STARTED
tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10001 + 200 PASSED
tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10002 + 200 STARTED
tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10002 + 200 PASSED

// @Unrollがない場合
tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】#CASE : #ACCOUNT_ID + #HTTP_STATUS STARTED
tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】#CASE : #ACCOUNT_ID + #HTTP_STATUS PASSED

setup句

setup句ではテストを行うための事前処理を行う際に記述します。

when句

実際に行う処理を記述します。

then句

処理結果の比較処理を行います。

when句

パラメタライズテストを行う際にデータを定義する事が出来ます。

Spockでは、マークダウンのテーブルのようにパイプ(|)で仕切る事で項目を定義する、データパイプと言う記法を使う事が出来ます。

1行目には項目(ヘッダー)を指定し、2行目以降に実際のパラメーターを定義する事で、その行単位で関数がsetup句から実行されていきます。

実行

では実際にユニットテストを実行してみましょう。

SPRING_PROFILES_ACTIVE=local ./gradlew test
> Task :api:test

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10001 + 200 STARTED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10001 + 200 PASSED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10002 + 200 STARTED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10002 + 200 PASSED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【異常系】対象のアカウントが存在しない事 : 1 + 404 STARTED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【異常系】対象のアカウントが存在しない事 : 1 + 404 PASSED

すべて正常に完了すれば上記のようにすべてPASSEDになると思います。

エラーがある場合はこんな感じになります。

SPRING_PROFILES_ACTIVE=local ./gradlew test
> Task :api:test

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10001 + 404 STARTED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10001 + 404 FAILED
    org.spockframework.runtime.ConditionFailedWithExceptionError at AccountDetailControllerSpec.groovy:54
        Caused by: java.lang.AssertionError at AccountDetailControllerSpec.groovy:54

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10002 + 200 STARTED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【正常系】対象のアカウントが存在する事 : 10002 + 200 PASSED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【異常系】対象のアカウントが存在しない事 : 1 + 404 STARTED

tech.blogenist.service.account.api.controller.account.detail.AccountDetailControllerSpec > 【異常系】対象のアカウントが存在しない事 : 1 + 404 PASSED

3 tests completed, 1 failed

> Task :api:test FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':api:test'.
> There were failing tests. See the report at: file:///Users/blogenist/develop/git/github/service-account/api/build/reports/tests/test/index.html

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 20s
11 actionable tasks: 2 executed, 9 up-to-date

エラーレポート

そして、Spockの一番の強みがユニットテストレポート機能標準搭載されている事です。

[adsense-responsive]

ユニットテスト失敗時のログに出ているファイルにアクセスしてみましょう。

おいおい神かよ。

シンプルで見やすいレポートが生成されています。

比較結果の詳細が記載されているので、調査がしやすいですね。

もちろん、すべてPASSEDになると以下のようなレポートが生成されます。

また、HTML形式なのでCIツールと合わせて結果をサーバーなどにおけば、ユニットテストの結果が管理もしやすいです。

終わりに

以上のように、今まではJUnit一択だったユニットテストですが、Spockと言う新しいフレームワークが登場した事でユニットテストの環境が変わってきています。

なんにせよ、ユニットテストの後回し自分の首を絞める事になるので必ず初期開発から導入するようにしてみると幸せになれると思います♪