テスト戦略: 層ごとのテストとモック
第 2 部では、章ごとにテストを足してきました。注釈の検証を Spring なしで確かめるテストもあれば、Testcontainers の PostgreSQL を起動してサービスを通すテストも、起動したサーバーに HTTP で送るテストもあります。この章では、これらをテストが起動する範囲で層に分けて整理し、まだ使っていない層 (Spring を使わずに部品を差し替えるテストと、Spring の一部だけを起動するスライス) を足します。
この章のコードは tag api-16 にあります。
テストの層
テストを、起動するものの範囲で 5 つの層に分けます。
| 層 | 起動するもの | このリポジトリの例 | 確かめられること |
|---|---|---|---|
| Spring を起動しない | 何も起動しない | CreateOrderRequestValidationTest・ProductSortTest・RequestErrorTest・この章の OrderServiceUnitTest | 分岐・計算・注釈の検証 |
| Web 層のスライス | Spring MVC とコントローラ | この章の OrderControllerWebMvcTest | URL の対応付け・リクエストの読み取りと検証・応答への変換 |
| 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 に、雛形の時点から入っています。
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 章で決めた、すべての明細を確かめてから書き込む順は、次のテストで確かめています。
/** ノートの在庫は 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),
)
}
@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。
@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 で確かめます。
@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 を、このスライスに移しました。
@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 です。
@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 が投げられます。
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 件も入っていません。
@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 がアプリのクラスを自動では見つけられないので、明示しています。