概要
データベースに依存するリポジトリクラスのテストは、環境構築の手間と実行速度がネックになりがちです。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 の本番寄り検証に切り替える
コード例
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());
}
}Version Coverage
record で User エンティティを定義でき、テストデータの記述が簡潔になる。テキストブロックで SQL を見やすく書ける。
// Java 17: record + テキストブロックで簡潔に
record User(long id, String name, String email) {}
String sql = """
INSERT INTO users (name, email)
VALUES (?, ?)
""";Library Comparison
注意点
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
全コネクションが閉じた時点で破棄されます。DB_CLOSE_DELAY=-1 で JVM 終了まで保持できますが通常は不要です。
基本的なデータ型は通りますが、ストアドプロシージャやトリガーは非対応です。H2 用に調整するのが確実です。
jdbc:h2:mem: の後に一意な DB 名を付ければ独立したインスタンスが作られます。
ローカルで素早く回したい CRUD テストや基本分岐の確認は H2、方言差や制約、マイグレーションを含めて本番寄りに見たい場面は Testcontainers が向いています。
小さなテストでは INIT でも十分ですが、複数テーブルや細かい初期データがある場合は @BeforeEach や SQL スクリプトに分けた方が追いやすくなります。