概要

データベースに依存するリポジトリクラスのテストは、環境構築の手間と実行速度がネックになりがちです。H2 Database のインメモリモードを使えば、JVM 上でデータベースが完結し、テストごとにクリーンな状態を再現できます。この記事では、H2 インメモリ DB を使って UserRepository の CRUD テストを実装する手順を示します。スキーマの初期化方法、テストごとのデータリセット戦略(DROP ALL OBJECTS と INIT スクリプト)、Apache Derby との使い分け、そして本番 DB との SQL 方言差に起因する落とし穴まで、実務で押さえるべきポイントを一通り整理します。Testcontainers との棲み分けについても触れ、どの段階でインメモリ DB からコンテナ DB へ移行すべきかの判断材料を示します。

使いどころ

新規開発の JDBC リポジトリに対し、CI 上で外部 DB なしに CRUD テストを高速実行する

バッチ処理の INSERT/UPDATE ロジックをインメモリ DB で検証し、本番デプロイ前にデータ不整合を検出する

リファクタリング時にテーブル構造変更の影響をインメモリ DB で素早く確認する

サービス層のテストで最小限の SQL 実行結果だけ確認し、DB 起動コストを抑えたい

開発初期にリポジトリの基本 CRUD を回し、後段で Testcontainers の本番寄り検証に切り替える

コード例

UserRepositoryTest.java
import java.sql.*;
import java.util.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class UserRepositoryTest {

    record User(long id, String name, String email) {}

    static class UserRepository {
        private final Connection conn;
        UserRepository(Connection conn) { this.conn = conn; }

        void save(String name, String email) throws SQLException {
            try (var ps = conn.prepareStatement("INSERT INTO users(name,email) VALUES(?,?)")) {
                ps.setString(1, name); ps.setString(2, email); ps.executeUpdate();
            }
        }
        Optional<User> findById(long id) throws SQLException {
            try (var ps = conn.prepareStatement("SELECT id,name,email FROM users WHERE id=?")) {
                ps.setLong(1, id);
                try (var rs = ps.executeQuery()) {
                    return rs.next() ? Optional.of(new User(rs.getLong(1), rs.getString(2), rs.getString(3))) : Optional.empty();
                }
            }
        }
        List<User> findAll() throws SQLException {
            var list = new ArrayList<User>();
            try (var st = conn.createStatement(); var rs = st.executeQuery("SELECT id,name,email FROM users ORDER BY id")) {
                while (rs.next()) list.add(new User(rs.getLong(1), rs.getString(2), rs.getString(3)));
            }
            return list;
        }
    }

    private Connection conn;
    private UserRepository repository;

    @BeforeEach void setUp() throws SQLException {
        conn = DriverManager.getConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
        try (var st = conn.createStatement()) {
            st.execute("CREATE TABLE IF NOT EXISTS users(id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(200) NOT NULL)");
        }
        repository = new UserRepository(conn);
    }

    @AfterEach void tearDown() throws SQLException {
        try (var st = conn.createStatement()) { st.execute("DROP ALL OBJECTS"); }
        conn.close();
    }

    @Test @DisplayName("保存して取得できる")
    void saveAndFind() throws SQLException {
        repository.save("田中太郎", "[email protected]");
        var found = repository.findById(1L);
        assertTrue(found.isPresent());
        assertEquals("田中太郎", found.get().name());
    }

    @Test @DisplayName("複数件を全件取得できる")
    void findAll() throws SQLException {
        repository.save("田中太郎", "[email protected]");
        repository.save("佐藤花子", "[email protected]");
        assertEquals(2, repository.findAll().size());
    }

    @Test @DisplayName("存在しない ID は空を返す")
    void notFound() throws SQLException {
        assertTrue(repository.findById(999L).isEmpty());
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record で User エンティティを定義でき、テストデータの記述が簡潔になる。テキストブロックで SQL を見やすく書ける。

Java 17
// Java 17: record + テキストブロックで簡潔に
record User(long id, String name, String email) {}
String sql = """
    INSERT INTO users (name, email)
    VALUES (?, ?)
    """;

Library Comparison

H2 Database(インメモリ)JDBC リポジトリの単体テストに最適。JVM 内で完結し起動が数十ミリ秒。本番 DB との SQL 方言差が残る。DDL やストアドプロシージャのテストには向かない。
Apache Derby(インメモリ)JDK に同梱されていた歴史があり、追加依存なしで使いたいとき。H2 と比べてドキュメントが少なく、MODE 切替もない。パフォーマンスも劣る。
Testcontainers本番と同じ DB エンジンでテストしたいとき。SQL 方言差がゼロ。Docker が必要で起動に数秒かかる。単純な CRUD テストにはオーバースペック。

注意点

H2 の SQL 方言は MySQL や PostgreSQL と完全互換ではない。AUTO_INCREMENT は IDENTITY に書き換える必要がある

インメモリモードはコネクションが全て閉じた時点でデータが消える。DB_CLOSE_DELAY=-1 でテスト間のデータ保持が可能

H2 の MODE=MySQL や MODE=PostgreSQL は一部の方言差を吸収できるが、ウィンドウ関数やストアドプロシージャの互換性は限定的

テストごとに DROP ALL OBJECTS を実行するリセット方式は安全だが遅い場合がある。TRUNCATE で済むならそちらを優先する

Derby は H2 より SQL 標準寄りだがドキュメントやコミュニティが小さい。特別な理由がなければ H2 を選ぶのが無難

本番で使うインデックスや制約が H2 では同じ形で再現できないことがある。DDL が通っただけで安心しない

JDBC URL をテスト間で共有すると並列実行時に干渉しやすい。クラス単位またはメソッド単位で DB 名を分けると安定する

FAQ

H2 インメモリ DB のデータはテスト終了後に消えますか。

全コネクションが閉じた時点で破棄されます。DB_CLOSE_DELAY=-1 で JVM 終了まで保持できますが通常は不要です。

H2 の MySQL モードで本番と同じ DDL を使えますか。

基本的なデータ型は通りますが、ストアドプロシージャやトリガーは非対応です。H2 用に調整するのが確実です。

テスト並列実行時にデータが衝突しませんか。

jdbc:h2:mem: の後に一意な DB 名を付ければ独立したインスタンスが作られます。

H2 と Testcontainers はどう使い分けますか。

ローカルで素早く回したい CRUD テストや基本分岐の確認は H2、方言差や制約、マイグレーションを含めて本番寄りに見たい場面は Testcontainers が向いています。

スキーマ初期化は INIT と @BeforeEach のどちらがよいですか。

小さなテストでは INIT でも十分ですが、複数テーブルや細かい初期データがある場合は @BeforeEach や SQL スクリプトに分けた方が追いやすくなります。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます