Controllerは @WebMvcTest で、アプリ全体は @SpringBootTest でテストできるようになったけれど、「Repositoryのクエリだけを軽く検証したい」となると手が止まりませんか。@SpringBootTest で確認するのは重すぎるし、かといってモックでは実際のSQLが正しいか分かりません。

そこで使うのが @DataJpaTest です。今回はRepository層に絞ったスライステストの書き方と、テスト用DBにH2を使うか実DB(Testcontainers)を使うかの判断基準を、実例つきで整理します。

@DataJpaTestはRepository層だけを起動する

@DataJpaTest を付けると、EntityManager・Spring Data Repository・DataSource といったJPA関連のBeanだけがロードされます。Service・Controller・@Component はコンテキストに含まれません。つまりRepository層だけを切り出して検証できるわけです。

テストには3つの層があると考えると整理しやすいですよね。

アノテーション対象起動範囲
@WebMvcTestController層Web層のみ
@DataJpaTestRepository層JPA関連Beanのみ
@SpringBootTestアプリ全体全Bean

@WebMvcTest ではServiceを @MockBean で差し替えましたが、Repositoryスライスは 実際にDBへクエリを発行すること自体が主題 です。ここでRepositoryをモックしても意味がないので、原則モックは使いません。

最小構成のスライステストを書く

まずは動く最小例から。@DataJpaTest を付けてRepositoryを @Autowired で注入するだけです。

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void save_then_findById() {
        User saved = userRepository.save(new User("alice", "[email protected]"));

        Optional<User> found = userRepository.findById(saved.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("alice");
    }
}

JUnit 5 と AssertJ を前提にしています。クラスパスにH2などの組み込みDBがあれば、設定なしで自動的にそれが使われます(この自動置換は後ほど詳しく見ます)。

自動ロールバックでテストが汚染されない

@DataJpaTest は各テストメソッドを @Transactional の中で実行し、終了時に 自動でロールバック します。テスト間でDBの状態が残らないので、実行順序を気にせず独立したテストが書けます。

便利な反面、落とし穴もあります。ロールバック前提なので、save() してもJPAの永続化コンテキスト(一次キャッシュ)にデータが載っているだけで、実SQLがDBに飛んでいないことがあるのです。findById() がキャッシュから値を返してしまい、マッピングやクエリのバグを見逃すケースですね。これを防ぐのが次のセクションの flush/clear です。

なお、ロールバックを止めて実際にコミットしたい場合は @Rollback(false)@Commit を使えます。デバッグ時にDBの中身を覗きたいときに便利です。

TestEntityManagerでデータを準備して読み直す

テストデータの準備には TestEntityManager を使います。Repository経由ではなく純粋なデータ投入として使い分けると、検証対象のRepositoryメソッドと準備処理が混ざらず読みやすくなります。

@DataJpaTest
class UserRepositoryQueryTest {

    @Autowired
    TestEntityManager em;

    @Autowired
    UserRepository userRepository;

    @Test
    void findByEmail_reads_from_db() {
        em.persistAndFlush(new User("bob", "[email protected]"));
        em.clear(); // 一次キャッシュを空にする

        Optional<User> found = userRepository.findByEmail("[email protected]");

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("bob");
    }
}

ポイントは persistAndFlush でSQLをDBへ反映し、clear でキャッシュを空にすること。これで findByEmail実際にDBから読み直す ことになり、エンティティマッピングやクエリの間違いをきちんと検出できます。getId でID取得、refresh で再読込みといった補助メソッドも用意されています。

@AutoConfigureTestDatabaseでH2と実DBを切り替える

@DataJpaTest は内部に @AutoConfigureTestDatabase を持っていて、デフォルト(Replace.ANY)では DataSource を組み込みH2へ置き換えます。application.yml でPostgreSQLを設定していても、テスト時はH2に差し替わるということです。

本番と同じDBで検証したいなら、置き換えを無効化します。

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryWithRealDbTest {
    // application設定のDataSource(PostgreSQL等)をそのまま使う
}

この選択の本質は H2方言と本番DB方言の差 です。関数名・データ型・SQL構文が微妙に違うため、H2で通ったクエリが本番で落ちることがあります。次のセクションでこのトレードオフを掘り下げます。

クエリメソッドと@Queryを検証する

derived query(findByXxx)は、複数件投入してから件数や順序で検証します。

@Test
void findByActiveTrueOrderByName() {
    em.persist(new User("carol", "[email protected]", true));
    em.persist(new User("alice", "[email protected]", true));
    em.persist(new User("dave", "[email protected]", false));
    em.flush();
    em.clear();

    List<User> active = userRepository.findByActiveTrueOrderByNameAsc();

    assertThat(active).extracting(User::getName)
        .containsExactly("alice", "carol");
}

@Query も同様に検証できます。JPQLはJPA実装が各DBの方言へ変換してくれますが、nativeQuery = true のネイティブクエリは DB方言にそのまま依存 します。ここがH2と実DBの差が出やすいところです。

@Query(value = "SELECT * FROM users WHERE email ILIKE %:keyword%", nativeQuery = true)
List<User> searchByEmail(@Param("keyword") String keyword);

ILIKE はPostgreSQL固有なので、H2では動きません。こうしたクエリはネイティブの時点で実DBでのテストを検討すべきです。Pageable を使うページングや @Modifying を伴う更新クエリでは、更新後に flush/clear してから読み直さないと古いキャッシュを見てしまう点にも注意しましょう。derived queryの書き方そのものは クエリメソッド入門 も参考にしてください。

H2とTestcontainersをどう使い分けるか

判断軸はシンプルで、速度を取るか本番一致性を取るかです。

  • H2インメモリ: 起動が速くCIでも軽量。ただしSQL方言・関数・型の差で、本番で動かないクエリを見逃すリスクがある。
  • Testcontainers(実DB): 本番同等のDBを使うので方言差まで検出できる。代わりにDockerコンテナ起動分のオーバーヘッドがある。

ざっくりした目安としては、標準的なderived query中心ならH2で十分です。ネイティブクエリやDB固有機能(upsert、ウィンドウ関数、特定の型など)を使う、あるいは本番一致性が重要なら、Testcontainersで実DBを使いましょう。両方を併用し、軽いテストはH2・方言依存のテストだけ実DB、という分け方も現実的です。

@DataJpaTestとTestcontainersを組み合わせる

スライスの軽さを保ったまま実DBに接続するには、まず Replace.NONE でH2置換を止め、コンテナの DataSource を使わせるのが要点です。

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

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    UserRepository userRepository;
    // 本番と同じPostgreSQLでネイティブクエリまで検証できる
}

@DynamicPropertySource でコンテナのJDBC URLや認証情報を注入するのが基本パターンです。コンテナをテストごとに立ち上げるとCI時間が伸びるので、Singletonコンテナパターンで使い回す手もあります。Testcontainers自体の導入は PostgreSQL/Kafka/Redisの記事 に詳しいので、そちらを参照してください。

ハマりやすいポイント

実務でよく踏むのは次のあたりです。save 後のアサーションが通るのにログにSQLが出ないときは、flush/clear 不足で一次キャッシュを見ているだけのことが多いです。H2では通るのにTestcontainersや本番で落ちるなら、関数名・型・upsert 構文といった方言差を疑いましょう。逆に Replace.NONE の付け忘れで、実DBのつもりが知らぬ間にH2で動いていることもあります。

まとめ

@DataJpaTest を使えば、Repository層だけを軽量に切り出して検証できます。TestEntityManager でデータを準備し、flush/clear でDBから読み直すのが確実な検証の基本でした。

DBの選択はH2(速度)とTestcontainers(本番一致)のトレードオフで決め、ネイティブクエリやDB固有機能を使うときは特に実DBを検討しましょう。Web層の @WebMvcTestJUnit/Mockitoの基礎 と組み合わせて、テスト戦略全体を組み立ててみてください。