1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Bootのテストを粒度で分ける ― スライステストとトランザクションの落とし穴

1
Last updated at Posted at 2026-09-08

Spring Bootのテストを粒度で分ける ― スライステストとトランザクションの落とし穴

テストクラスを増やしたら、mvn testが終わるまでコーヒーを淹れに行けるようになった。しかも、全部緑なのに本番でだけLazyInitializationExceptionが出る。どちらも、テストの粒度を選ばずに@SpringBootTestだけで書いたときに起きやすい症状です。

この回では、注文受付APIを題材に、Springを起動しないテスト、一部だけ起動するテスト、全部起動するテストを使い分けます。カバレッジ何%を目指すという話はしません。どのテストが何を保証していて、何を保証していないかを言えるようにするのが目的です。

この記事でわかること

  • Springを起動しないテストで足りる範囲と、足りなくなる境界
  • @WebMvcTest@DataJpaTestが、それぞれどこまで起動するか
  • テストに付けた@Transactionalが、本番と違う結果を出すからくり
  • @MockBeanから@MockitoBeanへの移行
  • Repositoryのテストを、組み込みDBで済ませるかTestcontainersへ寄せるかの判断

そのテストは、どこまで起動しているか

いちばん速いテストは、Springを起動しない

第2回でコンストラクタ注入にしておくと、Springを使わずにインスタンスを組み立てられます。

// 起動するもの:なし(DIコンテナもDBも使わない)
class OrderApplicationServiceTest {

    @Test
    void 在庫が足りなければ注文を受け付けない() {
        OrderRepository repository = new InMemoryOrderRepository();
        StockChecker stockChecker = productCode -> 0;  // 常に在庫ゼロ
        var service = new OrderApplicationService(repository, stockChecker);

        assertThatThrownBy(() ->
                service.register(new CreateOrderCommand("P-001", 3)))
                .isInstanceOf(OutOfStockException.class);
    }
}

このテストは、DIコンテナもDBも起動しません。判断の分岐を検証したいだけなら、この粒度で足ります。 業務ルールが増えるほど分岐は増えるので、いちばん数が多くなるのはこの層です。ここが薄いまま@SpringBootTestだけが増えると、テスト時間が伸びる割に、分岐の網羅は進みません。

逆に、この層で確認できないものもはっきりしています。URLのマッピング、JSONの項目名、Bean Validationの発火、SQLの発行、トランザクションの境界。これらはすべてSpringが用意した仕組みの上で起きるので、Springを起動しないと検証できません。

スライステストで、必要な部分だけ起動する

Spring Bootには、アプリケーション全体ではなく特定の層だけを起動するテストアノテーションがあります。

@WebMvcTest:HTTPの入口だけ

// 起動するもの:Spring MVCまわりのみ。Service・Repositoryは起動しない
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    OrderApplicationService service;

    @Test
    void 数量がゼロなら400を返す() throws Exception {
        mockMvc.perform(post("/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"productCode":"P-001","quantity":0}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.title").value("Validation Failed"));
    }
}

@WebMvcTestは、Spring MVCまわり(Controller、@RestControllerAdvice、JSON変換、Bean Validation)を起動し、@Service@Repositoryは登録しません。だから、依存するServiceは@MockitoBeanで差し替えます。

第4回で設計したエラー応答を固定できるのは、この粒度です。 ステータスコードとJSONの項目名は、Controllerの中ではなくSpringの変換とAdviceが決めているので、Springを起動しない単体テストでは検証できません。かといって全体を起動する必要もありません。

なお、@WebMvcTestはフィルターチェーンも対象に含みます。Spring Securityを導入済みのプロジェクトでこのテストを書くと、認証されていないリクエストとして401や403が返り、意図した検証まで届かないことがあります。認証済みユーザーやCSRFトークンもテストの条件に含めてください。 通らないからといって、理由なくフィルターを無効化しないことです。

@MockBeanは非推奨になりました

Spring Boot 3.4から@MockBean@SpyBeanは非推奨(将来の削除が予告されています)で、Spring Frameworkが提供する@MockitoBean@MockitoSpyBeanが後継です。既存プロジェクトを3.4以降へ上げると、この2つが警告として大量に出ます。import元も変わります(org.springframework.boot.test.mock.mockitoorg.springframework.test.context.bean.override.mockito)。

単純な型指定のフィールドは置換だけで移りますが、Bean名を指定しているもの、同じ型の候補が複数あるもの、コンテキスト階層を使っているものは、挙動を確認してから移してください。

@DataJpaTest:永続化まわりだけ

// 起動するもの:Entity・Repository・EntityManager・DataSource。Controllerは起動しない
@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    OrderRepository repository;

    @Autowired
    TestEntityManager entityManager;

    @Test
    void 商品コードで注文を検索できる() {
        repository.save(new Order("P-001", 2));
        repository.save(new Order("P-002", 1));

        assertThat(repository.findByProductCode("P-001")).hasSize(1);
    }

    @Test
    void 商品コードは必須() {
        // persistからflushまでを検証対象のラムダへ入れる
        assertThatThrownBy(() ->
                entityManager.persistAndFlush(new Order(null, 1)))
                .isInstanceOf(PersistenceException.class);
    }
}

@DataJpaTestは、EntityとRepository、EntityManagerDataSourceを起動し、Controllerや@Serviceは登録しません。既定ではクラスパスに組み込みDB(H2など)があればそちらへ置き換えます。実DBを使いたい場合は@AutoConfigureTestDatabase(replace = NONE)で明示的に止めます。

テストの@Transactionalは、本番と挙動が違う

ここがいちばん事故が多い場所です。

テストのトランザクションは、本番より広い

@DataJpaTestは既定でトランザクション内で実行され、テスト終了時にロールバックされます。DBを汚さないための親切な既定です。ところが、この「テスト全体がひとつのトランザクション」という状態が、本番の実行とは違う条件を作ります。

症状1:SQLが発行されないまま検証が通る。 JPAはsave()を呼んだ時点で必ずINSERTを流すわけではなく、フラッシュまで持ち越す場合があります。同じトランザクション内なら永続化コンテキスト(1次キャッシュ)から取れてしまうため、実際にはDBの制約に触れていないのに検証が通ることがあります。NOT NULLや一意制約の違反を確認したいなら、先ほどのコードのようにpersistAndFlush()を使います。

ただし、SQLの時点は採番方式に依存します。今回のGenerationType.IDENTITYでは、IDを得るためにpersist()時点でINSERTされ、制約違反もそこで発生しました。だからpersist()をassertionの外へ置くと、検証前にテストが失敗します。「必ずflushまでSQLが出ない」ではなく、DBへ確実に到達させる操作をassertionの中へ含めると覚えてください。

症状2:本番でだけLazyInitializationExceptionが出ることがある。 テストメソッド全体がトランザクション内なので、遅延ロードの関連を後から触っても通ります。ただし本番側が必ず落ちるわけではありません。落ちるのは、spring.jpa.open-in-viewを無効にしたうえで、Serviceのトランザクションが終わったあと(たとえばDTOへの詰め替え)に遅延関連へ触る設計のときです。第5回で扱ったOSIVを有効にしたままなら、本番でも通ってしまいます。どちらにせよ、テスト全体のトランザクションが判断を隠していることに変わりはないので、「トランザクションはどこで閉じるのか」をテストの条件としても意識してください。

症状3:@SpringBootTestに付けた@Transactionalの効き方は、呼び出し方で変わります。 ここは@SpringBootTestだから、と一括りにできません。

テスト管理のトランザクションは、テストを実行しているスレッドに結び付きます。したがって、テストメソッドからServiceを同じスレッドで直接呼ぶ場合は、Serviceの@Transactionalが外側のテスト側トランザクションへ参加し、コミット後の挙動が隠れます。第6回で扱った伝播の理屈が、テストでもそのまま効いている状態です。

一方、webEnvironment = RANDOM_PORT実際のHTTPを送る場合は、サーバー側の処理が別スレッドで動き、別のトランザクションになります。こちらではテスト側のロールバックがサーバー側の更新を戻してくれません。後始末をテストのロールバック任せにできないので、自分で消すか、テストデータを衝突しない値にする必要があります。

ロールバックそのものを検証したいなら、いずれの場合もテストへ@Transactionalを付けず、後始末を自分で書くほうが確実です。

Spring Boot 3.5.16、Hibernate ORM 6.6.53.Final、H2でこの差を確認しました。また、Serviceの途中で例外を投げるテストでは、注文INSERTと在庫変更がどちらもロールバックされました。テスト側へ@Transactionalを付けず、実装側の境界を検証しています。

@SpringBootTestは、いちばん最後に少しだけ

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {

    @Autowired
    TestRestTemplate restTemplate;

    @Test
    void 注文を登録して取得できる() {
        var created = restTemplate.postForEntity("/orders",
                new CreateOrderRequest("P-001", 2), OrderResponse.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}

@SpringBootTestはアプリケーション全体を起動します。webEnvironmentRANDOM_PORTにすると組み込みサーバーまで立ち上がり、実際のHTTPで叩けます。配線がつながっていること、設定が効いていることを確認する用途には、これしかありません。

一方で、起動が重く、失敗したときに原因の範囲が広いという性質があります。数本に絞り、代表的な導線だけを通すのが現実的です。

同じ設定の@SpringBootTestは、コンテキストが使い回されます

Springのテストフレームワークは、設定が同じならApplicationContextをキャッシュして再利用します。「同じ設定」の判定には、構成クラス、プロファイル、テスト用プロパティ、@DynamicPropertySource@MockitoBeanの組み合わせなどが含まれます。モック構成を1つ変えるだけで別のコンテキストになり、そのたびに起動が走ります。 キャッシュには上限があり、超えたぶんは破棄されて再起動になります。

テストが遅いときは、テストの本数だけでなく生成されたApplicationContextの種類数を確認してください。org.springframework.test.context.cacheのログレベルをDEBUGにすると、キャッシュの統計が出ます。@DirtiesContextも同じ理由で、必要な場所だけに絞ります。

実務ではこうなる

Repositoryのテストは、本番と同じDBで走らせます。 @DataJpaTestは既定でH2などの組み込みDBへ置き換えますが、本シリーズではこれを止め、Testcontainersで本番と同じDBを立てる方針を採ります。

@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {

    // 例ではPostgreSQL 16。本番で採用するメジャーバージョンへ合わせる
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    // Repositoryのテストは前掲の例と同じ
}

理由は、H2との差がSQL方言だけではないからです。型の扱い、NOT NULLや一意制約の効き方、日付時刻、採番、ロックと分離レベル、DDLとマイグレーションの適用結果。永続化のテストで確かめたいことの多くが、まさにDBごとに違う部分です。 「H2で通ったのに本番で落ちる」を避けるには、Repositoryの層だけは本番DBに合わせるのが確実です。

@AutoConfigureTestDatabase(replace = NONE)だけでは、組み込みDBへの置き換えを止めるだけです。上の例では@Testcontainers@ContainerがPostgreSQLを起動し、Spring Boot 3.1以降の@ServiceConnectionが接続情報を自動でプロパティへ流し込みます。以前のように@DynamicPropertySourceでURLを手書きする必要はありません。

この例を動かすには、テスト依存関係としてspring-boot-testcontainers、Testcontainersのjunit-jupiterpostgresqlモジュールが必要です。バージョンはSpring Boot 3.5.16のdependency managementへ合わせます。

DB製品名だけでなく、本番と同じメジャーバージョンをテスト条件として固定します。FlywayやLiquibaseを使うシステムでは、テストでも同じマイグレーションを適用してからRepositoryを検証します。コンテナだけ本番DBへ寄せても、スキーマの作り方が本番と違えば確認条件は揃いません。

代償は実行時間とDockerへの依存です。CIでDockerが使えることが前提なので、採用前に構成を確認してください。単体テストと@WebMvcTestは引き続きDBなしで動くので、日常的に回すのはそちらで、Repositoryのテストは実行時間を見ながら流し方を決めます。

Testcontainers導入前後の時間は、マシン、Dockerのキャッシュ、並列数、コンテナ再利用で大きく変わります。本記事では一般化できる秒数を載せません。採用するCIで、初回とキャッシュ後を分けて測り、待ち時間の上限と実行タイミングを決めてください。

テストが遅いと、誰も待たなくなります。 これは技術の問題ではなく運用の問題です。単体テストとスライステストは常に流し、@SpringBootTestとTestcontainersを使うテストは別のタスクへ分ける、といった分離が要ります。分離できるように、粒度でパッケージやタグを分けておいてください。

「テストがある」と「壊れたら気付ける」は別です。 正常系だけのテストは、リファクタリングの安全網としては弱い。第4回のエラー応答、第6回のロールバック、この2つは意図的に失敗させるテストがないと守れません。書く順番に迷ったら、壊れたときに本番で困る順に書くのが実際的です。

検証プロジェクトでは、Springを起動しないService単体テスト1件、@WebMvcTest 1件、@DataJpaTest 2件、ApplicationContext全体の起動1件、Serviceのロールバック1件、合計6件がJava 17・Spring Boot 3.5.16で成功しました。Testcontainers例は公式サポートに基づく構成ですが、この作業環境にはDocker Engineがないため実コンテナ起動は未実施です。コードを採用する際は、CI上でPostgreSQLの起動、接続、マイグレーション、終了処理まで確認してください。

テストを書く前に決める4点

  1. 何を保証したいか(分岐か、配線か、DBの挙動か)
  2. その保証に、Springの起動が本当に要るか
  3. 要るなら、どのスライスで足りるか
  4. トランザクションはテストが持つのか、実装が持つのか

次回はシリーズ最終回として、ここまで作ったアプリケーションを実行可能JARからコンテナへ載せ、デプロイまでを扱います。

公式リファレンス

参考文献・参照資料

  • Mark Heckler, Spring Boot:立ち上げて実行する、ISBN 9798341626911(AI翻訳版):第9章「Spring Bootアプリケーションをテストして本番環境への対応力を高める」の単体テスト・テストスライスの考え方を参照。原著は2021年・Spring Boot 2.4系のため、@MockitoBeanやTestcontainersサポートなど本記事の3.x固有の内容は公式ドキュメントで確認しています。
  • Somnath Musib, Spring Boot in Practice, Manning, ISBN 9781617298813:第7章のRESTful APIテストの節を参照。

書籍のコード・図・訳文は転載せず、架空の注文受付サンプルとして書き直しています。

シリーズ内リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?