かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot 1.5.x の Web アプリを 2.0.x へバージョンアップする ( その17 )( Spock を 1.1-groovy-2.4 → 1.2-groovy-2.5 へバージョンアップする )

概要

記事一覧はこちらです。

Spring Boot 1.5.x の Web アプリを 2.0.x へバージョンアップする ( その16 )( Gradle を 4.10 → 4.10.2 へ、Spring Boot を 2.0.4 → 2.0.6 へバージョンアップする ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Spock の 1.2 がリリースされていましたのでバージョンアップします。
    • Spock のリリースされたバージョンを見ると 1.2-groovy-2.5, 1.2-groovy-2.4 の2つがありましたので、1.2-groovy-2.5 を採用し Groovy のバージョンも 2.5 に上げます。
    • Spock 1.2 から JDK 11 がサポートされます。

参照したサイト・書籍

  1. Allow usage of Groovy 2.5
    https://github.com/spring-projects/spring-boot/issues/13444

  2. Release Notes - Peter Niederwieser, The Spock Framework Team Version 1.2
    http://spockframework.org/spock/docs/1.2/release_notes.html

  3. Changelog for Groovy 2.5.0
    http://www.groovy-lang.org/changelogs/changelog-2.5.0.html

  4. spock/docs/module_spring.adoc
    https://github.com/spockframework/spock/blob/master/docs/module_spring.adoc

  5. Spock 1.2 Annotations for Spring Integration Testing
    https://objectpartners.com/2018/06/14/spock-1-2-annotations-for-spring-integration-testing/

  6. Spocklight: Indicate Specification As Pending Feature
    http://mrhaki.blogspot.com/2017/06/spocklight-indicate-specification-as.html

  7. REST Client Testing With MockRestServiceServer
    https://objectpartners.com/2013/01/09/rest-client-testing-with-mockrestserviceserver/

  8. SPR-14458 - Mock Exception happening pre/during http call
    https://github.com/spring-projects/spring-framework/pull/1954/files

  9. No further requests expected while MockRestServiceServer was set to ExpectedCount.manyTimes()
    https://stackoverflow.com/questions/50239757/no-further-requests-expected-while-mockrestserviceserver-was-set-to-expectedcoun/50241367#50241367

目次

  1. build.gradle を変更する
  2. @SpringBean, @SpringSpy を試してみる
  3. @PendingFeature を試してみる
  4. @Retry を試してみる

手順

build.gradle を変更する

dependencyManagement {
    imports {
        // mavenBom は以下の URL のものを使用する
        // https://repo.spring.io/release/org/springframework/boot/spring-boot-starter-parent/2.0.6.RELEASE/
        // bomProperty に指定可能な property は以下の URL の BOM に記述がある
        // https://repo.spring.io/release/org/springframework/boot/spring-boot-dependencies/2.0.4.RELEASE/spring-boot-dependencies-2.0.6.RELEASE.pom
        mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) {
            // Spring Boot の BOM に定義されているバージョンから変更する場合には、ここに以下のように記述する
            // bomProperty "thymeleaf.version", "3.0.9.RELEASE"
            bomProperty "groovy.version", "2.5.4"
        }
    }
}

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:42.2.5"
    def spockVersion = "1.2-groovy-2.5"
    def domaVersion = "2.19.3"
    ..........

Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると、"BUILD SUCCESSFUL" のメッセージが出力されました。

f:id:ksby:20181118200842p:plain

@SpringBean, @SpringSpy を試してみる

Spock 1.2 から @SpringBean, @SpringSpy というアノテーションが追加されました。

src/main/java/ksbysample/webapp/lending/helper/user/UserHelper.java のテストを作成して試してみます。

@Component
public class UserHelper {

    private final UserInfoDao userInfoDao;

    public UserHelper(UserInfoDao userInfoDao) {
        this.userInfoDao = userInfoDao;
    }

    public String[] getApprovalMailAddrList() {
        List<String> approvalMailAddrList = userInfoDao.selectApproverMailAddrList();
        return Iterables.toArray(approvalMailAddrList, String.class);
    }

}

まずは @SpringBean から。@SpringBean アノテーションを付けると Spock の Mock を Bean として自動的に Spring の Test 用 Context に追加してくれます。

package ksbysample.webapp.lending.helper.user

import ksbysample.webapp.lending.dao.UserInfoDao
import org.spockframework.spring.SpringBean
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification

@SpringBootTest
class UserHelper2Test extends Specification {

    // = Mock() を記述すること
    @SpringBean
    UserInfoDao userInfoDao = Mock()

    @Autowired
    UserHelper userHelper

    def "getApprovalMailAddrListメソッドのテスト"() {
        when:
        def approvalMailAddrList = userHelper.getApprovalMailAddrList()

        then:
        // 呼び出された回数のチェック("1 *")と、戻り値の定義が同時にできる
        1 * userInfoDao.selectApproverMailAddrList() >> ["tanaka.taro@sample.com"]
        approvalMailAddrList == ["tanaka.taro@sample.com"]
    }

}

f:id:ksby:20181119224648p:plain

次は @SpringSpy。@SpringSpy を付けると Mock ではなく実クラスのインスタンスを Bean として Spring の Test 用 Context に追加してくれます。またメソッド呼び出し回数を検査できるようにもなります。

package ksbysample.webapp.lending.helper.user

import ksbysample.webapp.lending.dao.UserInfoDao
import org.spockframework.spring.SpringSpy
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import spock.lang.Specification

@SpringBootTest
class UserHelper2Test extends Specification {

    @SpringSpy
    UserInfoDao userInfoDao

    @Autowired
    UserHelper userHelper

    def "getApprovalMailAddrListメソッドのテスト"() {
        when:
        def approvalMailAddrList = userHelper.getApprovalMailAddrList()

        then:
        // 呼び出された回数のチェック("1 *")と、戻り値の定義が同時にできる
        // 戻り値を定義すればそれが使われるし、定義しなければ本来の Bean のメソッドの処理が実行される
//        1 * userInfoDao.selectApproverMailAddrList() >> ["tanaka.taro@sample.com"]
        1 * userInfoDao.selectApproverMailAddrList()
        approvalMailAddrList == ["tanaka.taro@sample.com", "suzuki.hanako@test.co.jp"]
    }

}

f:id:ksby:20181119225343p:plain

簡単なクラスなのにテストに 20数秒かかります。。。 これくらいならば、@SpringBean, @SpringSpy を使わずにシンプルに Mock() でテストした方が早いかもしれません。以下の書き方だと 2秒未満で終わります。

package ksbysample.webapp.lending.helper.user

import ksbysample.webapp.lending.dao.UserInfoDao
import spock.lang.Specification

class UserHelper2Test extends Specification {

    UserInfoDao userInfoDao = Mock()

    UserHelper userHelper

    def setup() {
        userHelper = new UserHelper(userInfoDao)
    }

    def "getApprovalMailAddrListメソッドのテスト"() {
        setup:
        userInfoDao.selectApproverMailAddrList() >> ["tanaka.taro@sample.com"]

        when:
        def approvalMailAddrList = userHelper.getApprovalMailAddrList()

        then:
        approvalMailAddrList == ["tanaka.taro@sample.com"]
    }

}

f:id:ksby:20181119230154p:plain

@PendingFeature を試してみる

@PendingFeature はテストが失敗する場合に failed ではなく ignored にするアノテーションです。1.2 から追加された機能ではありませんが、初めて見たので試してみます。

例えば、以下のように失敗するテストがあると通常 BUILD も失敗となりますが、

package ksbysample.webapp.lending.helper.user

import ksbysample.webapp.lending.dao.UserInfoDao
import spock.lang.Specification

class UserHelper2Test extends Specification {

    UserInfoDao userInfoDao = Mock()

    UserHelper userHelper

    def setup() {
        userHelper = new UserHelper(userInfoDao)
    }

    def "getApprovalMailAddrListメソッドのテスト"() {
        when:
        def approvalMailAddrList = userHelper.getApprovalMailAddrList()

        then:
        approvalMailAddrList == ["tanaka.taro@sample.com"]
    }

}

f:id:ksby:20181119232825p:plain

@PendingFeature が付与されたテストは失敗しても failed ではなく ignored となり、BUILD は成功します。

package ksbysample.webapp.lending.helper.user

import ksbysample.webapp.lending.dao.UserInfoDao
import spock.lang.PendingFeature
import spock.lang.Specification

class UserHelper2Test extends Specification {

    UserInfoDao userInfoDao = Mock()

    UserHelper userHelper

    def setup() {
        userHelper = new UserHelper(userInfoDao)
    }

    @PendingFeature
    def "getApprovalMailAddrListメソッドのテスト"() {
        when:
        def approvalMailAddrList = userHelper.getApprovalMailAddrList()

        then:
        approvalMailAddrList == ["tanaka.taro@sample.com"]
    }

}

f:id:ksby:20181119233307p:plain

ちょっと面白い機能だと思いますが、開発中なら merge しなければよさそうな気がしますし、これ使う場面あるのかな。。。

@Retry を試してみる

1.2 から追加された機能です。マニュアルは こちら

src/main/java/ksbysample/webapp/lending/service/calilapi/CalilApiService.java に定義している restTemplateForCalilApi と、

        @Bean
        public RestTemplate restTemplateForCalilApi() {
            return this.restTemplateBuilder
                    .setConnectTimeout(CONNECT_TIMEOUT)
                    .setReadTimeout(READ_TIMEOUT)
                    .rootUri(CalilApiService.URL_CALILAPI_ROOT)
                    .build();
        }

MockRestServiceServer を利用して @Retry のテストを作成してみましたが、以下のことが分かりました。

  • MockRestServiceServer は RestTemplate の requestFactory の定義を変更してしまうため、RestTemplate の readTimeout のテストは作成できない。
  • Spock のテストに @Slf4j が記述できるが、これは groovy.util.logging.Slf4j。lombok.extern.slf4j.Slf4j では "log.~" と記述してもエラーになる。
  • Groovy 2.5 から Java 8 の lambda は .andRespond { request -> withTimeout().createResponse(request) } のように記述できる。

作成してみたテストは以下のものです。最後の def "リトライして4回目で200OKのレスポンスが返る場合のテスト"() に @Retry アノテーションを付加しており、3回は SocketTimeoutException が throw されますが 4回目で成功することでテスト自体は成功します。

※(2018/12/09追記)MockRestServiceServer を利用するテストクラスには @DirtiesContext アノテーションを付けること。RestTemplate のインスタンスを Bean として生成・利用している場合、MockRestServiceServer が RestTemplate Bean を変更するため、他のテストが失敗することがあります。

package ksbysample.webapp.lending.service.calilapi

import groovy.util.logging.Slf4j
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpRequest
import org.springframework.http.client.ClientHttpResponse
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.ResponseCreator
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestTemplate
import spock.lang.Retry
import spock.lang.Specification

import static ksbysample.webapp.lending.service.calilapi.CalilApiService2Test.TimeoutResponseCreator.withTimeout
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess

@Slf4j
@SpringBootTest
@DirtiesContext
class CalilApiServiceRetryTest extends Specification {

    /**
     * タイムアウト用 ResponseCreator
     * Spring Framework 5.1 になると
     * responseCreator = MockRestResponseCreators.withException(SocketTimeoutException).createResponse(null)
     * という書き方が出来るらしいので、おそらくこのようなクラスを定義する必要はない
     */
    static class TimeoutResponseCreator implements ResponseCreator {

        @Override
        ClientHttpResponse createResponse(ClientHttpRequest request) throws IOException {
            if (true) {
                throw new SocketTimeoutException("Testing timeout exception")
            }
            return null
        }

        static TimeoutResponseCreator withTimeout() {
            return new TimeoutResponseCreator()
        }

    }

    @Autowired
    RestTemplate restTemplateForCalilApi

    def responseJson = """
                        {
                            "key": "sample",
                            "value": 1
                        }
                    """.stripIndent()

    def retryCount

    def setup() {
        retryCount = 0
    }

    def "200OKでレスポンスが返る場合のテスト"() {
        given:
        MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplateForCalilApi).build()
        mockServer.expect(requestTo(CalilApiService.URL_CALILAPI_ROOT + "/sample/"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess(responseJson, MediaType.APPLICATION_JSON_UTF8))

        when:
        def result = restTemplateForCalilApi.getForObject("/sample/", String)

        then:
        result == responseJson
    }

    def "タイムアウトする場合のテスト"() {
        given:
        MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplateForCalilApi).build()
        mockServer.expect(requestTo(CalilApiService.URL_CALILAPI_ROOT + "/sample/"))
                .andExpect(method(HttpMethod.GET))
                .andRespond { request -> withTimeout().createResponse(request) }

        when:
        def result = restTemplateForCalilApi.getForObject("/sample/", String)

        then:
        ResourceAccessException e = thrown()
        e.cause instanceof SocketTimeoutException
    }

    @Retry
    def "リトライして4回目で200OKのレスポンスが返る場合のテスト"() {
        given:
        retryCount++
        ResponseCreator responseCreator
        if (retryCount == 4) {
            responseCreator = withSuccess(responseJson, MediaType.APPLICATION_JSON_UTF8)
        } else {
            responseCreator = { request -> withTimeout().createResponse(request) }
        }

        // MockRestServiceServer.bindTo(...).build() が呼ばれると RestTemplate の requestFactory が変更されるらしい
        // つまり restTemplateForCalilApi のタイムアウトの設定も変更されるので、restTemplateForCalilApi のタイムアウト
        // のテストにはならないようだ
        MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplateForCalilApi).build()
        mockServer.expect(requestTo(CalilApiService.URL_CALILAPI_ROOT + "/sample/"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(responseCreator)

        when:
        log.warn("calling...")
        def result = restTemplateForCalilApi.getForObject("/sample/", String)
        log.error("finished!!")

        then:
        notThrown ResourceAccessException
        result == responseJson
    }

}

f:id:ksby:20181123171833p:plain

履歴

2018/11/23
初版発行。
2018/12/09
* IntelliJ IDEA を 2018.2.6 → 2018.2.7 → 2018.3.1 へバージョンアップ の記事を反映して、CalilApiServiceRetryTest テストクラスに @DirtiesContext アノテーションを追加しました。