しおしお

IntelliJ IDEAのことなんかを書いてます

Spring Cloud Contractを試してみた

Spring Cloud Contractとは

Spring Cloud Contractは、Consumer-Driven Contract testing1を実現するためのフレームワークです。

お試し構成

Producerをserver-serviceとして、Consumerをclient-serviceとして構築しています。

f:id:sioiri:20200831091436p:plain

Producer(server-service)

build.gradle

dependenciesにspring-cloud-starter-contract-verifierをpluginにorg.springframework.cloud.contractを追加します。これで、契約を元にしたテストコードの生成と実行、Stubの生成などが行われるようになります。

設定を変更したい場合には、contractsに対してオプションを指定します。指定できるオプションは、Spring Cloud Contract: Configuration Optionsにまとまっています。 baseClassForTestsについては、生成されるテストコードの親クラスを指定します。

import org.springframework.cloud.contract.verifier.config.TestFramework

plugins {
  id "org.springframework.cloud.contract" version scContractVersion
}
apply plugin: 'maven'

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}

dependencies {
  testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier")
}

contracts {
  testFramework = TestFramework.JUNIT5
  basePackageForTests = 'siosio'
  baseClassForTests = 'siosio.BaseTestClass'
}

アプリケーション側の実装

GET sample/{id}で受け取ったidnameを追加したjsonを返すだけのサーバサイドにしています。

package siosio

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.*

@SpringBootApplication
class ServerSideApplication

fun main(args: Array<String>) {
    runApplication<ServerSideApplication>(*args)
}

@RestController
@RequestMapping("/sample")
class SampleController(
    private val sampleService: SampleService
) {

    @GetMapping("{id:\\d+}")
    fun get(@PathVariable id: Int): SampleResponse {
        return sampleService.get(id)
    }
}

@Service
class SampleService {
    fun get(id: Int): SampleResponse {
        return SampleResponse(id, "name")
    }
}

data class SampleResponse(
    val id: Int,
    val name: String,
)

@ResponseStatus(HttpStatus.NOT_FOUND)
class NotFoundException : RuntimeException()

テストコードの親クラス

build.gradleで指定した親クラスの実装になります。 検証対象のエンドポイントを持つControllerがどれかを、RestAssuredMockMvc.standaloneSetupに設定します。 Controllerが依存しているServiceをモック化したい場合には、このクラスでモックを生成して設定してあげることができます。

abstract class BaseTestClass {

    private val mockService: SampleService = mockk()

    @BeforeEach
    open fun setup() {
        val slot = slot<Int>()
        every { mockService.get(capture(slot)) } answers {
            if (slot.captured == 999) throw NotFoundException()
            SampleResponse(slot.captured, "name_${slot.captured}")
        }
        RestAssuredMockMvc.standaloneSetup(SampleController(mockService))
    }
}

Contractの定義

Contractはデフォルト設定では、src/t/est/resources/contracts に定義します。今回は、正常系と404を返すケースのContractを定義してみます。 Contractの定義方法は、Contract DSLを見るとだいたいわかります。

正常系(src/t/est/resources/contracts/GetSample.groovy)

SampleControllerで定義しているエンドポイントを呼び出すrequestを定義しています。 responseでは、requestで送ったidnameが戻ってくることを定義します。

import org.springframework.cloud.contract.spec.Contract

Contract.make {
  priority 2

  request {
    method('GET')
    url "/sample/${regex('\\d+')}"
  }

  response {
    status(OK())

    headers {
      contentType(applicationJson())
    }
    
    body(
        id: fromRequest().path(1),
        name: "name_${fromRequest().path(1)}"
    )
  }
}

404系(src/t/est/resources/contractsGetSample_404.groovy)

期待するresponseとして404を定義します。priorityを正常系より高くして404を優先的に扱えるようにしてあげます。

import org.springframework.cloud.contract.spec.Contract

Contract.make {
  priority 1
  request {
    method('GET')
    url ("/sample/999")
  }

  response {
    status(NOT_FOUND())
  }
}

Stubの生成とMavenローカルリポジトリへのインストール

buildとインストールタスクを実行してStubの生成とMavenローカルリポジトリへのインストールを行います。 なお、Producer側のContractに従っているかの検証だけであれば、./gradlew testだけでOKです。

./gradlew  build install

テストコードが生成され、Contractに従っているかの検証が行われます。 今回は、正常系と404系の2ケースともContractに従っていることがわかります。

f:id:sioiri:20200901061109p:plain

Consumer(client-service)

build.gradle

dependenciesにspring-cloud-starter-contract-stub-runnerを追加します。これで、Producer側で生成したStubを使用したテストコードがかけるようになります。

dependencies {
  testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner")
}

Producer側を呼び出すコード

RestTemplateを使ってエンドポイントを叩くだけの実装です。実際は、urlは設定ファイルに切り出したりとかが必要になると思いますが…

@Service
class SampleService(
    private val restTemplate: RestTemplate
) {
    fun get(id: Int): SampleApiResponse {
        return restTemplate.getForEntity("http://localhost:8080/sample/{id}", SampleApiResponse::class.java, id).body!!
    }
}

data class SampleApiResponse(
    val id: Int,
    val name: String,
)

テストコード

Producer側と違い、テストコードを作成する必要があります。

AutoConfigureStubRunnerアノテーションを使ってどのStubを使うのかを指定します。 idアノテーションにstubの情報を指定します。大事なのは、最後に指定するポート(8080の部分)は実際にProducerを呼び出す際に指定しているポートを指定します。 Mavenのローカルリポジトリを使っているので、stubsModeにはLOCALを指定します。 リモートリポジトリを使う場合には、REMOTEを設定しrepositoryRootを設定する必要があるようです。

package siosio

import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties
import org.springframework.test.context.TestConstructor
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.web.client.HttpClientErrorException

@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureStubRunner(ids = ["siosio:server-service:1.0.0:stubs:8080"], stubsMode = StubRunnerProperties.StubsMode.LOCAL)
internal class SampleServiceTest(
    private val sut: SampleService
) {

    @Test
    internal fun 正常系() {
        val actual = sut.get(100)
        Assertions.assertThat(actual)
            .isEqualTo(SampleApiResponse(100, "name_100"))
    }
    
    @Test
    internal fun `404系`() {
        Assertions.assertThatThrownBy { sut.get(999) }
            .isInstanceOf(HttpClientErrorException.NotFound::class.java)
    }
}

テスト結果

テスト成功ですね。これでConsumer側もContractに従っていることが検証できました。

f:id:sioiri:20200901062957p:plain

ソースコード全体

github.com