Springテストのパフォーマンス最適化
テスト時間を短くしたい。Pull RequestだしてCI終わるまで何分も待ってるの何かなーと。
重たいのはテスト毎にSpringのコンテキストを使用しているかららしい。Springのコンテキストつまりテストに@SpringBootTestを付けていてテスト毎に差し替えるMockが異なると、動作としてはSpringを立ち上げてBeanを登録し、Mockに差し替え、終われば落とし、次のテストを実行するためまたSpringが立ち上がり…みたいなことになっているらしい。テスト時間を短くするためにそもそもSpringのコンテキスト(@SpringBootTest)を使わない、使う場合はテスト毎に設定しないで共通化する方針で修正する。もう一つ、そもそも不要なテストケースがあるかもしれないため必要なテストケースをテストする。
テスト見直しの方針
Springのコンテキスト(SpringBootTest)を使わない
Springのコンテキストを使う必要があるのはこのSpringFrameworkに頼ってる場所だけで良いはず。フレームワークが色々対応してくれているController層、Repository層にだけに使用し、Serviceはもしかしたら一部使用し、Domainは使用せずテストを書くべき。Service層もコンストラクタインジェクションを使用しSpringの機能であるAutoWiredを使用しなければ、基本Springコンテキストは使用しなくて良いはず。
テスト毎に設定しないで共通化
Controller層、Repository層をテストする場合はSpringのコンテキストを必要とするが、何もテストケース毎に@SpringBootTestをつける必要は無いため共通化する。
必要なテストケースをテスト
俗にテストスイートとか言うらしい。持っている基準が組み合わせテストになると数が爆発するため良くない、ぐらいしか無いので単体テストの考え方/使い方を買って読んでみている。全然読み終わって無いため書いたテストを減らせるか考えた所感を書く。
コード
whenとかthenとかあったほうがテストが読み易いと思っているためSpock+Groovyを使用する。
▶ build.gradle
plugins {
id 'java'
id 'groovy'
id 'org.springframework.boot' version '3.5.4'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
// テストコードでGroovyとJavaの両方を使用可能にする
sourceSets {
test {
groovy {
srcDirs = ['src/test/groovy', 'src/test/java']
}
}
}
dependencies {
// Spring関連
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// その他ライブラリ
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'io.vavr:vavr:0.9.0'
// テスト関連
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'org.spockframework:spock-core:2.4-M5-groovy-4.0'
testImplementation 'org.spockframework:spock-spring:2.4-M5-groovy-4.0'
// フォーマッター
implementation 'com.google.googlejavaformat:google-java-format:1.28.0'
}
tasks.named('test') {
useJUnitPlatform()
}
▶ ディレクトリ構成
src/main/java/com/example/demo
├── DemoApplication.java
├── controller
│ └── yu_packet
│ ├── form
│ │ ├── FieldForm.java
│ │ └── YuPacketTrackingNumberForm.java
│ ├── refer
│ │ └── YuPaketReferController.java
│ └── registration
│ ├── YuPaketRegistrationController.java
│ ├── request
│ │ └── YuPacketRequest.java
│ └── response
│ ├── YuPacketResponse.java
│ └── YuPacketResponseImpl.java
├── domain
│ └── yu_packet
│ ├── Depth.java
│ ├── Height.java
│ ├── InvalidatedReason.java
│ ├── Weight.java
│ ├── Witdth.java
│ ├── YuPacketRegistrationCommand.java
│ ├── YuPacketTrackingNumber.java
│ ├── YuPacketValidator.java
│ ├── YuPaket.java
│ └── YuPaketCreateCommand.java
├── exception
│ └── GlobalRestControllerExceptionHandler.java
└── service
└── YuPaketRegistrationService.java
src/test/java/com/example/demo
├── controller
│ └── yu_packet
│ └── registration
│ ├── YuPaketRegistrationControllerSpec.groovy
│ └── request
│ └── FixtureYuPacketRequest.groovy
├── domain
│ └── yu_packet
│ ├── DepthSpec.groovy
│ ├── FixtureDepth.groovy
│ ├── FixtureHeight.groovy
│ ├── FixtureInvalidatedReason.groovy
│ ├── FixtureWeight.groovy
│ ├── FixtureWitdth.groovy
│ ├── FixtureYuPacketRegistrationCommand.groovy
│ ├── FixtureYuPacketTrackingNumber.groovy
│ ├── FixtureYuPaket.groovy
│ └── FixtureYuPaketCreateCommand.groovy
├── service
│ └── YuPaketRegistrationServiceSpec.groovy
└── specifications
├── ApiSpecification.groovy
└── BaseSpecification.groovy
Domain
テスト対象
Depth.java
テスト対象。ゆうパケットの長辺(Depth)長さは0以上34cm以下らしいのでそれを表現したドメイン。(いまさら名前Depthじゃ無いなと思ったが保留)
コンストラクタメソッドのofでDepthを作る。バリデーションをパスできずDepthを作れなかった場合はexceptionとかは投げず、Option.noneを返し呼び出した側に制御は委ねる意図で作成
package com.example.demo.domain.yu_packet;
import io.vavr.control.Option;
import lombok.Data;
@Data
public class Depth {
private final int value;
public static Option<Depth> of(int depth) {
return validate(depth) ? Option.of(new Depth(depth)) : Option.none();
}
public static Boolean validate(int depth) {
return 0 < depth && depth <= 34;
}
}
テスト
提供しているメソッドはof(),validation()なのでそれぞれテストする。境界値がテストできればいいか観点でテストを書いた。また特にSpringのお世話になっていないため、コンテキスト周りの設定とかはない。
DepthSpec
package com.example.demo.domain.yu_packet
import spock.lang.Specification
import spock.lang.Unroll
@Unroll
class DepthSpec extends Specification {
def "Return Option.some when given value is valid #useCase"() {
when:
def actual = Depth.of(value)
then:
actual.isDefined()
actual.get().value == value
where:
useCase | value
"lower boundary" | FixtureDepth.VALID_MIN_DEPTH
"upper boundary" | FixtureDepth.VALID_MAX_DEPTH
}
def "Return Option.none when given value is invalid #useCase"() {
when:
def actual = Depth.of(value)
then:
actual.isEmpty()
where:
useCase | value
"out of lower boundary" | FixtureDepth.INVALID_LESS_DEPTH
"out of lower upper boundary" | FixtureDepth.INVALID_ABOVE_DEPTH
}
def "Return correct boolean #useCase"() {
when:
def actual = Depth.validate(value)
then:
actual == expected
where:
useCase | value | expected
"valid lower boundary" | FixtureDepth.VALID_MIN_DEPTH | true
"valid upper boundary" | FixtureDepth.VALID_MAX_DEPTH | true
"invalid out of lower boundary" | FixtureDepth.INVALID_LESS_DEPTH | false
"invalid out of upper boundary" | FixtureDepth.INVALID_ABOVE_DEPTH | false
}
}
FixtureDepth
境界値はマジックナンバーになるのでFixture側にまとめて定義しておいた。つまるところこのドメインの構造ではテストとして若干変更に弱いため、ドメイン側に定数として定義してもいいかとも思った。
package com.example.demo.domain.yu_packet
class FixtureDepth {
static final DEFAULT_DEPTH = 10
static final VALID_MIN_DEPTH = 1
static final VALID_MAX_DEPTH = 34
static final INVALID_LESS_DEPTH = 0
static final INVALID_ABOVE_DEPTH = 35
static Depth get() {
return Depth.of(DEFAULT_DEPTH).get()
}
}
減らせる?
of()の中で結局validate()呼んでいるのでof()だけのテストで良くないか?とも思う。ただ振る舞いとしてof(),validate()は別ものであるためそれぞれテストすべきとも思う。んー今のそれぞれの振る舞いをテストすべきと思うため、減らせない派とする。
Service
テスト対象
controllerからYuPacketを登録するコマンドYuPacketRegistrationCommandを受け取り、コマンドをvalidationする。パスすればDBに登録(面倒なので登録はすっ飛ばし、登録できれば払い出されるYuPacketTrackingNumberをnewで雑に作る)、ドメインであるYuPacketを作って返すサービス層をテストする。
YuPaketRegistrationService
package com.example.demo.service;
import com.example.demo.domain.yu_packet.YuPacketRegistrationCommand;
import com.example.demo.domain.yu_packet.YuPacketTrackingNumber;
import com.example.demo.domain.yu_packet.YuPacketValidator;
import com.example.demo.domain.yu_packet.YuPaket;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class YuPaketRegistrationService {
private final YuPacketValidator yuPacketValidator;
public YuPaket register(YuPacketRegistrationCommand command) {
return yuPacketValidator
.validate(command)
.fold(
invalidatedReasons -> YuPaket.invalid(invalidatedReasons),
createCommand -> YuPaket.valid(createCommand, new YuPacketTrackingNumber(1)));
}
}
テスト
Controllerから受け取ったYuPacketRegistrationCommandのvalidate結果次第で振る舞いが変わるため、validate()だけモックにした。具体的にはvalidateをパスしてYuPaketを作成するYuPacketCreateCommandを返すか、パスできずエラーをまとめたInvalidatedReasonのSeqを受け取るかをモックにした。 それ以上foldの内部までモックにするのはやり過ぎな気がするため、上記2パターンだけテストすることにした。
モックもSpockのMockかMockitoのMockどちらを使用するか選択肢があるが、あんまりにも単純なテストなためどっちでも良い。。なんとなくSpockのMockで書いた。理由は無い。
YuPaketRegistrationServiceSpec
package com.example.demo.service
import com.example.demo.domain.yu_packet.FixtureInvalidatedReason
import com.example.demo.domain.yu_packet.FixtureYuPacketRegistrationCommand
import com.example.demo.domain.yu_packet.FixtureYuPaket
import com.example.demo.domain.yu_packet.FixtureYuPaketCreateCommand
import com.example.demo.domain.yu_packet.YuPacketValidator
import io.vavr.collection.List
import io.vavr.control.Validation
import spock.lang.Specification
class YuPaketRegistrationServiceSpec extends Specification {
private YuPacketValidator yuPacketValidatorMock = Mock(YuPacketValidator)
private YuPaketRegistrationService sut = new YuPaketRegistrationService(yuPacketValidatorMock)
def "Return validate YuPaket when given YuPaketCreateCommand"() {
when:
def actual = sut.register(FixtureYuPacketRegistrationCommand.get())
then:
actual == FixtureYuPaket.validYuPaket()
1 * yuPacketValidatorMock.validate(_) >> Validation.valid(FixtureYuPaketCreateCommand.get())
}
def "Return invalid when given invalid YuPaketCreateCommand"() {
when:
def actual = sut.register(FixtureYuPacketRegistrationCommand.get())
then:
actual == FixtureYuPaket.invalidYuPaket()
1 * yuPacketValidatorMock.validate(_) >> Validation.invalid(List.of(FixtureInvalidatedReason.get()))
}
}
AI修正案
結局registerに渡しているFixtureYuPacketRegistrationCommandは意味が無いため(振る舞いはこの入力値では無くモック側で制御している)、意味ないですよ感を出せれば良いかも?と思った。のでAIに聞いてみたら準備段階であるgivenブロックを追加し、明示的に何でも良いことを表すコマンド名anyCommandにしたら?と提案された。こっちの方が二アンスが伝わるため採用。
def "Return invalid when given invalid YuPaketCreateCommand"() {
given:
// モックが振る舞いを決定
def anyCommand = FixtureYuPacketRegistrationCommand.get()
when:
def actual = sut.register(anyCommand)
then:
actual == FixtureYuPaket.invalidYuPaket()
1 * yuPacketValidatorMock.validate(_) >> Validation.invalid(List.of(FixtureInvalidatedReason.get()))
}
FixtureYuPaketCreateCommand
他のFixtureを詰め合わせるだけだが一応Fixtureも作った
package com.example.demo.domain.yu_packet
class FixtureYuPaketCreateCommand {
static YuPaketCreateCommand get() {
return new YuPaketCreateCommand(
FixtureWitdth.get(),
FixtureHeight.get(),
FixtureDepth.get(),
FixtureWeight.get()
)
}
}
FixtureInvalidatedReason
package com.example.demo.domain.yu_packet
class FixtureInvalidatedReason {
static final DEFAULT_INVALIDATED_REASON = "Default invalidated reason"
static InvalidatedReason get() {
return new InvalidatedReason(
DEFAULT_INVALIDATED_REASON
)
}
}
減らせる?
この2パターンが最小限だろと思う。逆に足りない?の視点で言えば、、、んー、、、.validate()はseqで複数のinvalid reasonを受け取れるがFixtureは1個しかinvalid reasonを持っていないため、複数invalid reasonを持つ場合のテストをするとかだろうか?結局foldに渡った後のYuPaket.invalid(invalidatedReasons)部分はYuPaketの部分でテストすればいいし、また複数inbalid reasonを受け取って正しく振る舞えるかはController側のテスト責務な気がする。今のところ2個で十分との立場。
Controller
テスト時間を悪化させてる主要因であるControllerのテスト。
テスト内容はDTOの構造とcontroller層のバリデーション 数値編で実施した通りであり、テスト内容に変更は無く特に今回は扱わない。
ControllerはSpringの機能にお世話になっているため、テストする際Springコンテキストを毎回立ち上げる。コレが重くテスト時間を悪化させるため極力一度作成したSpringコンテキストを使い回すようにテストを修正する。
今回のテストでは@SpringBootTestでコンテキストを作成、し@MockitoBeanにてMockに差し替えている。この処理を切り出し各Controllerのテストで使い回すように修正する。
テスト対象
YuPaketRegistrationController
package com.example.demo.controller.yu_packet.registration;
import com.example.demo.controller.yu_packet.registration.request.YuPacketRequest;
import com.example.demo.controller.yu_packet.registration.response.YuPacketResponse;
import com.example.demo.domain.yu_packet.YuPacketRegistrationCommand;
import com.example.demo.service.YuPaketRegistrationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class YuPaketRegistrationController {
private final YuPaketRegistrationService service;
private final YuPacketResponse response;
@RequestMapping(value = "/yu-paket", method = RequestMethod.POST)
public ResponseEntity<?> invoke(@Validated @RequestBody YuPacketRequest yuPacketRequest) {
YuPacketRegistrationCommand command = yuPacketRequest.toCommand();
return service.register(command).mapEither(response::success, response::validatedFailure);
}
}
テスト
YuPaketRegistrationControllerSpec
一部抜粋
class YuPaketRegistrationControllerSpec extends ApiSpecification {
def "200 ok"() {
setup:
Mockito.when(yuPaketRegistrationServiceMock.register(Mockito.any())).thenReturn(FixtureYuPaket.validYuPaket())
when:
def result = mockMvc.perform(
post("/yu-paket")
.content(mapper.writeValueAsString(FixtureYuPacketRequest.getValidRequest()))
.contentType("application/json"))
then:
result.andExpect(status().isOk())
result.andExpect(jsonPath('$.id').value(FixtureYuPacketTrackingNumber.DEFAULT_TRACKING_NUMBER))
}
}
コンテキストの使いまわし
ApiSpecification
@SpringBootTestと@MockitoBeanは合わせてApiSpecificationに切り出した。Controllerをテストする際はこれを使い回せば良いし、モックすべきServiceが増えればここに足していく方針とする。こうすればコンテキストは1回しか作り直されないためテスト時間を短くできる(はず)。
package com.example.demo.specifications
import com.example.demo.service.YuPaketRegistrationService
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.context.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.MockMvc
@SpringBootTest
@AutoConfigureMockMvc
class ApiSpecification extends BaseSpecification {
@Autowired
protected MockMvc mockMvc
@MockitoBean
protected YuPaketRegistrationService yuPaketRegistrationServiceMock
}
BaseSpecification
json←→object変換の際はmapperを使用する。これもAutoWiredでインジェクションしているためSpringの機能を使う。これはおそらくControllerとDataSourceどちらでも使用すると思われるため、両方から使用できるようApiSpecificationから1回層下げBaseSpecificationに定義してみた。
package com.example.demo.specifications
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Autowired
import spock.lang.Specification
class BaseSpecification extends Specification {
@Autowired
protected ObjectMapper mapper
}
まとめ
Springコンテキストを使用しテストするController層では、コンテキストを使い回せるように修正した。 また、使用していない箇所に関してはテストケースが減らせるか?見つつテストを書いてみた。あんまり観点を持っていないため減らす判断ができなかった。とりあえず本を読み切ろうと思う。