メインコンテンツまでスキップ

テスト戦略: 層ごとのテストとモック

第 2 部では、章ごとにテストを足してきました。注釈の検証を Spring なしで確かめるテストもあれば、Testcontainers の PostgreSQL を起動してサービスを通すテストも、起動したサーバーに HTTP で送るテストもあります。この章では、これらをテストが起動する範囲で層に分けて整理し、まだ使っていない層 (Spring を使わずに部品を差し替えるテストと、Spring の一部だけを起動するスライス) を足します。

この章のコードは tag api-16 にあります。

テストの層​

テストを、起動するものの範囲で 5 つの層に分けます。

層起動するものこのリポジトリの例確かめられること
Spring を起動しない何も起動しないCreateOrderRequestValidationTest・ProductSortTest・RequestErrorTest・この章の OrderServiceUnitTest分岐・計算・注釈の検証
Web 層のスライスSpring MVC とコントローラこの章の OrderControllerWebMvcTestURL の対応付け・リクエストの読み取りと検証・応答への変換
Data JDBC のスライスSpring Data JDBC・Flyway・データベースOrderRepositoryTest集約の読み書き・SQL
アプリ全体アプリのすべての bean とデータベースOrderServiceTest・OrderServiceRollbackTest・OrderServiceCancelRollbackTest・ProductSummaryQueryTestサービスからデータベースまでの通し・トランザクション
実際のサーバーアプリ全体とサーブレットコンテナOrderControllerTest・ApiExceptionHandlerTest・記事の curl の例HTTP の応答そのもの

13 章の @JsonTest も、JSON の読み書きの部品だけを起動するスライスです。

下の層ほど起動するものが少なく、落ちたときに原因の場所が絞れます。上の層ほど、部品の組み合わせと、設定や配線の誤りまで見られます。どの層にも、そこでしか確かめられないことがあるので、1 つの層に寄せずに使い分けます。

Spring を起動しないテストと MockK​

OrderService は、3 つの repository と、ID の採番と時計を受け取ります。12 章のテストは、これらを Spring に用意させ、Testcontainers の PostgreSQL に商品と在庫を入れて確かめました。

repository の代わりに、決めた値を返すだけの偽物 (mock) を渡せば、Spring もデータベースも使わずに OrderService を作れます。Kotlin のクラスの mock を作るライブラリには、Spring Boot の文書も勧める MockK を使います1。7 章で見た libs.versions.toml に、雛形の時点から入っています。

app/src/test/kotlin/com/example/shop/order/OrderServiceUnitTest.kt (抜粋)
class OrderServiceUnitTest {
private val orderRepository = mockk<OrderRepository>()
private val productRepository = mockk<ProductRepository>()
private val inventoryRepository = mockk<InventoryRepository>()

private val orderId = UUID.fromString("019a1c00-0000-7000-8000-000000000001")
private val orderedAt = Instant.parse("2026-10-24T01:00:00Z")
private val service = OrderService(
orderRepository = orderRepository,
productRepository = productRepository,
inventoryRepository = inventoryRepository,
idGenerator = { orderId },
clock = Clock.fixed(orderedAt, ZoneOffset.UTC),
)

mockk<OrderRepository>() が mock を作ります。ID の採番は関数 1 つの interface (fun interface) なので、ラムダをそのまま渡せます。時計は、決まった時刻を返す Clock.fixed です。

mock の関数が何を返すかは、every { ... } returns ... で決めます。12 章で決めた、すべての明細を確かめてから書き込む順は、次のテストで確かめています。

app/src/test/kotlin/com/example/shop/order/OrderServiceUnitTest.kt (抜粋)
/** ノートの在庫は 50、マーカーは 3。 */
private fun stubProductsAndInventories() {
every { productRepository.findAllById(any()) } returns listOf(notebook, marker)
every { inventoryRepository.findAllById(any()) } returns listOf(
Inventory(productId = notebook.id, quantity = 50, version = 0),
Inventory(productId = marker.id, quantity = 3, version = 0),
)
}
app/src/test/kotlin/com/example/shop/order/OrderServiceUnitTest.kt (抜粋)
@Test
fun `どれかの明細の在庫が足りなければ、どの在庫も注文も保存しない`() {
stubProductsAndInventories()
// save にも答えを決めておき、呼ばれたかどうかを verify で判定する。答えが無いと、呼ばれた時点で mock が例外を
// 投げ、verify まで届かない。
every { inventoryRepository.save(any<Inventory>()) } answers { firstArg() }
every { orderRepository.save(any<Order>()) } answers { firstArg() }

val result = service.create(command(notebook to 2, marker to 5))

assertEquals(CreateOrderResult.InsufficientInventory(listOf(Shortage(marker.id, requested = 5, available = 3))), result)
verify(exactly = 0) { inventoryRepository.save(any<Inventory>()) }
verify(exactly = 0) { orderRepository.save(any<Order>()) }
}

verify(exactly = 0) { ... } は、その呼び出しが 1 回も無かったことを確かめます2。データベースを使うテストでは、在庫の数が変わっていないことから「保存しなかった」と推し量るしかありません。mock なら、保存の関数を呼ばなかったことを直接確かめられます。同じファイルの成功のテストは、verifyOrder で、2 つの在庫の保存の後に注文を保存した順も確かめています。

MockK の mock は、every で決めていない関数を呼ぶと例外を投げます (strict)2。テストが想定していない呼び出しを、黙って見逃しません。

このテストでは、呼ばないはずの save にも答えを決めています。答えが無いと、サービスが誤って save を呼んだときに mock がその場で例外を投げてテストが落ち、verify の行は判定に使われません。答えを決めておけば、呼んだかどうかを verify の行が判定します。answers { firstArg() } は、受け取った 1 つ目の引数をそのまま返す答えです。

このテストで確かめられないものもあります。@Transactional は Spring が作る仕組みなので、Spring を使わずに作った OrderService では働きません。途中の例外で書き込みが戻ることは、12 章の OrderServiceRollbackTest と OrderServiceCancelRollbackTest がアプリ全体を起動して確かめます。SQL が正しいかも、データベースを使うテストの役目です。

Web 層のスライス​

Spring Boot には、アプリの一部の層だけを起動するテストの注釈があり、スライス と呼ばれます。@WebMvcTest は、コントローラ・@ControllerAdvice・JSON の変換など、Spring MVC に関わる bean だけを読み込み、サービスや repository やデータベースは読み込みません3。

app/src/test/kotlin/com/example/shop/order/OrderControllerWebMvcTest.kt (抜粋)
@WebMvcTest(OrderController::class)
@Import(RandomTrackingIdGenerator::class)
class OrderControllerWebMvcTest(@Autowired private val mvc: MockMvcTester) {

@MockkBean
private lateinit var orderService: OrderService

OrderController が受け取る OrderService は、スライスには無いので、@MockkBean で MockK の mock を bean として置きます。@MockkBean は SpringMockK というライブラリの注釈で、Spring の context の中の bean を mock に差し替えます4。

@Import(RandomTrackingIdGenerator::class) は、15 章の advice のためです。スライスは @RestControllerAdvice の ApiExceptionHandler を読み込みますが、advice がコンストラクタで受け取る追跡 ID の bean は、ふつうの @Component なので読み込みません。足さないと、advice を作れずに context の起動で落ちます。

リクエストは MockMvcTester で送ります5。サーブレットコンテナを起動せずに、Spring MVC にリクエストを渡す道具で、結果を AssertJ の assertThat で確かめます。

app/src/test/kotlin/com/example/shop/order/OrderControllerWebMvcTest.kt (抜粋)
@Test
fun `検証に通らないリクエストは、入力の誤りの 400 にし、サービスを呼ばない`() {
// 検証を通り抜けてサービスが呼ばれたら、201 が返ってステータスコードの行で落ちるよう、答えを決めておく。
every { orderService.create(any()) } returns CreateOrderResult.Created(order)

assertThat(postOrder("""{"contactEmail": "", "lines": []}"""))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson().extractingPath("$.errors[*].pointer").isEqualTo(listOf("#/contactEmail", "#/lines"))
verify(exactly = 0) { orderService.create(any()) }
}

検証の違反は、コントローラの関数を呼ぶ前に Spring MVC が断るので、サービスは呼ばれません。サービスの mock には、201 になる答えを決めています。検証が効いていなければ、サービスが呼ばれて 201 が返り、ステータスコードを確かめる行でこのテストは落ちます。extractingPath は、応答の JSON から JSON Path で値を取り出します。同じクラスの作成のテストは、リクエストの JSON がどの CreateOrderCommand になってサービスに渡ったかを、slot に受けて確かめています。

MockMvcTester はサーブレットコンテナを通らないので、例外がエラーページ (/error) へ転送されません3。15 章の advice が作る Problem Details は、転送の前に Spring MVC の中で作るので、上のテストのようにスライスでも本文を確かめられます。advice で扱わない例外の応答を確かめるときは、OrderControllerTest のように実際のサーバーに送ります。

Data JDBC のスライス​

@DataJdbcTest は、Spring Data JDBC の repository と、Flyway と、データベースへの接続だけを起動するスライスです。テストはそれぞれトランザクションの中で動き、終わりにロールバックします3。9 章で @SpringBootTest で書いた OrderRepositoryTest を、このスライスに移しました。

app/src/test/kotlin/com/example/shop/order/OrderRepositoryTest.kt (抜粋)
@DataJdbcTest
@Import(TestcontainersConfiguration::class, ClockConfiguration::class)
class OrderRepositoryTest(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val productRepository: ProductRepository,
@Autowired private val clock: Clock,
) {

スライスは repository のほかの bean を読み込まないので、テストが使う時計の設定 (ClockConfiguration) を @Import で足しています。

Spring Boot の文書には、@DataJdbcTest は既定で組み込みのメモリ上のデータベースに置き換えるので、実際のデータベースを使うなら @AutoConfigureTestDatabase を付ける、という説明と例があります3。Spring Boot 4.1 では、この注釈は要りません。置き換えるかどうかの既定は「テスト用のデータベースでなければ置き換える」で、@ServiceConnection を付けた Testcontainers のコンテナは、テスト用のデータベースとして扱われるからです6。

Testcontainers のコンテナと context​

Spring のテストの仕組みは、起動した context (bean の入れ物) をキャッシュし、同じ設定のテストのクラスで使い回します。設定が同じかどうかは、テストのクラスに付けた注釈から決まります。@Import するクラス、@ActiveProfiles の profile、@SpringBootTest の properties、@MockkBean で差し替える bean などが 1 つでも違えば、別の context を起動します7。

このアプリのテストのコンテナは、8 章で見た TestcontainersConfiguration の bean です。

app/src/test/kotlin/com/example/shop/TestcontainersConfiguration.kt (抜粋)
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

/** context ごとに 1 つ立てる。context の数だけコンテナが立ち、DB はほかの context と分かれる。 */
@Bean
@ServiceConnection
fun postgresContainer(): PostgreSQLContainer = PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE))

companion object {
/**
* テストで立てる PostgreSQL の image。latest にすると、新しいメジャー版が出た時点でテストの前提が黙って変わる
* ので、メジャー版で固定する。app/compose.yaml の image も同じ版にそろえる (YAML からはこの定数を参照できない)。
*/
const val POSTGRES_IMAGE = "postgres:18"
}
}

bean のコンテナは、context ごとに 1 回起動し、context が閉じると止まります8。データベースを持つ context の数だけ、PostgreSQL が立つということです。Web 層と JSON のスライスの context はデータベースを持たないので、コンテナを立てません。このほかに、8 章の練習問題のテストが、JUnit の @Testcontainers で自分でコンテナを立てます。context の数は Spring のテストの仕組みが出すキャッシュの統計で、コンテナの数はテストの出力の Testcontainers のログで数えられます。キャッシュの統計は、org.springframework.test.context.cache のログを DEBUG にすると出ます。

OrderRepositoryTest をスライスに移す前は、このテストは ShopApplicationTests などと同じ context を使っていました。スライスに移したので、context とコンテナが 1 つずつ増えています。スライスは 1 つの context を軽くしますが、context の数は増やします。

コンテナを 1 つにして、すべての context で使い回す書き方もあります (@ImportTestcontainers で static のコンテナを取り込む形)。このアプリでは採りません。共有したコンテナは、取り込んだ context のどれかが閉じると、その context の bean の後始末で止まります。ほかの context はキャッシュに残ったまま、止まったコンテナにつながろうとして落ちます。@DirtiesContext を付けたテストのクラスで context を閉じさせると、この順で落ちることを確かめました。context は、キャッシュの上限 (既定 32 個) を超えて古いものが追い出されたときにも閉じます7。Spring Boot の文書も、context の bean がコンテナに頼るなら、コンテナを bean として管理するよう勧めています8。

context ごとに DB が分かれているので、記事の curl の例のテスト (ResponseSnippetTest) は、クラスの初めに Flyway で DB を消して作り直せます。コンテナを共有すると、この操作がほかの context のテストのデータまで消します。

テストの時間とコンテナを減らしたいときは、context の数を減らします。同じ層のテストのクラスで、@Import や profile の組み合わせをそろえます。@MockkBean で同じ bean を差し替えるなら、フィールドの名前もそろえます。Spring Framework の文書も、フィールドの名前の違いで context が増えることを注意しています9。

テストデータ​

テストに要るデータの入れ方は、テストがロールバックするかで決めます。

ロールバックするテスト (Data JDBC のスライスと、@Transactional を付けた @SpringBootTest) では、テストのメソッドの中か @BeforeEach で repository に保存します。12 章の OrderServiceTest がこの形です。SQL のファイルで入れたいときは、Spring のテストの @Sql 注釈を使えます10。どちらの書き方でも、テストが終われば消えます。

開発の初期データ (db/seed) は、dev の profile のときだけ入ります。初期データの商品を前提にするテストには、OrderControllerTest のように @ActiveProfiles("dev") を付けます。dev の profile を付けないテストの DB には、スライスでもアプリ全体を起動するテストでも、初期データがありません。

実際のサーバーに送るテスト (RANDOM_PORT) では、ロールバックは効きません。サーバーはテストと別のスレッドでリクエストを処理するので、サーバーで始まったトランザクションは、テストのトランザクションと別になるからです3。記事の curl の例のテストは、クラスの初めに Flyway の clean と migrate で、DB を初期データだけの状態に戻しています。

本番で効く注意点​

mock で通ったテストは、本物との組み合わせを保証しない​

mock は、テストを書いた人が「本物はこう返すはず」と決めた値を返します。repository の SQL が誤っていても、mock を使うテストは通ります。mock を使う層のテストを増やしても、データベースまで通すテストと、実際のサーバーに送るテストは減らさないでください。下の層で分岐を細かく確かめ、上の層で組み合わせを少数の代表の例で確かめる、という分担にします。

まとめ​

  • テストは、起動する範囲で、Spring を起動しない・Web 層のスライス・Data JDBC のスライス・アプリ全体・実際のサーバーの 5 つの層に分けられます。層ごとに確かめられることが違うので使い分けます
  • MockK で repository の mock を作れば、Spring もデータベースも使わずにサービスの分岐と書き込みの順を確かめられます。トランザクションと SQL は、データベースを使うテストで確かめます
  • @WebMvcTest はコントローラと Spring MVC だけを起動します。サービスは @MockkBean で差し替え、advice が使う bean は @Import で足します
  • @DataJdbcTest は repository とデータベースだけを起動し、テストごとにロールバックします。Spring Boot 4.1 では、Testcontainers のコンテナに向けるのに @AutoConfigureTestDatabase は要りません
  • bean のコンテナは context ごとに立ちます。context の数を減らすには、テストの設定をそろえます。コンテナを context の間で共有すると、1 つの context が閉じたときにほかが落ちます
  • テストデータは、ロールバックするテストではテストの中で入れ、実際のサーバーに送るテストでは DB を作り直して戻します

次に読む​

この章で第 2 部の Spring Boot の基礎は終わりです。前の章は エラーレスポンス です。

次は第 3 部 (執筆中) です。第 3 部では、認証と認可を扱います。

練習問題​

MockK で作った OrderRepository の mock に、every で何も決めずに findById を呼ぶと、どうなりますか

解答例

MockKException が投げられます。

StrictMockTest.kt (抜粋)
val orderRepository = mockk<OrderRepository>()

val exception = assertThrows<MockKException> {
orderRepository.findById(UUID.fromString("019a1c00-0000-7000-8000-000000000001"))
}

assertTrue(exception.message.orEmpty().startsWith("no answer found for"))

MockK の mock は、既定で、決めていない呼び出しを誤りとして扱います2。テストの対象が、テストを書いた人の想定していない repository の関数を呼んだら、テストが落ちて知らせます。

何も決めずにすべての関数から単純な値 (0 や空など) を返させる mock は、mockk<OrderRepository>(relaxed = true) で作れます。ただし、想定していない呼び出しも黙って通るので、このガイドでは使いません。

Data JDBC のスライスのテストで、開発の初期データの商品 (ノートなど) を読もうとしました。どうなりますか

解答例

初期データの商品は見つかりません。このテストは dev の profile を付けていないので、DB に商品が 1 件も入っていません。

SliceWithoutSeedTest.kt (抜粋)
@DataJdbcTest
@ContextConfiguration(classes = [ShopApplication::class])
@Import(TestcontainersConfiguration::class)
class SliceWithoutSeedTest(@Autowired private val productRepository: ProductRepository) {

@Test
fun `dev の profile を付けないテストの DB には、初期データの商品が無い`() {
assertEquals(0, productRepository.count())
}
}

初期データの場所 (db/seed) を Flyway に足しているのは、dev の profile の設定 (application-dev.yaml) だけです。profile を付けないテストでは、Flyway はテーブルを作るマイグレーション (db/migration) だけを当てます。スライスのクラスに @ActiveProfiles("dev") を足すと、初期データの商品が入ります。

初期データの ID を前提にしたテストをスライスに移すときは、テストの中で商品を保存するように書き直します。@ContextConfiguration は、このテストのクラスが com.example.shop の外 (exercises.ch16) にあり、Spring Boot がアプリのクラスを自動では見つけられないので、明示しています。


Footnotes​

  1. 出典: Spring Boot 4.1.2 の文書の Kotlin Support の Testing の節 ("To mock Kotlin classes, MockK is recommended.")。 ↩

  2. 出典: MockK 1.14.11 の README の DSL examples・Verification・Relaxed mock の節。 ↩ ↩2 ↩3

  3. 出典: Spring Boot 4.1.2 の文書の Testing Spring Boot Applications。 ↩ ↩2 ↩3 ↩4 ↩5

  4. 出典: SpringMockK 5.0.1 の README。 ↩

  5. 出典: Spring Framework 7.0.10 の文書の AssertJ Integration (MockMvc の AssertJ の対応)。 ↩

  6. 出典: Spring Boot 4.1.2 の AutoConfigureTestDatabase のソース (replace の既定は Replace.NON_TEST)。 ↩

  7. 出典: Spring Framework 7.0.10 の文書の Context Caching。 ↩ ↩2

  8. 出典: Spring Boot 4.1.2 の文書の Testcontainers。 ↩ ↩2

  9. 出典: Spring Framework 7.0.10 の文書の @MockitoBean and @MockitoSpyBean の WARNING。 ↩

  10. 出典: Spring Framework 7.0.10 の文書の Executing SQL Scripts。 ↩