概要
H2 などのインメモリデータベースでテストを書いていると、「本番は PostgreSQL なのにテストでは H2 で通ってしまう」「PostgreSQL 固有の関数を使うとテストできない」という場面に遭遇します。Testcontainers は Docker コンテナをテストのライフサイクルに合わせて自動的に起動・破棄するライブラリで、本番と同じデータベースエンジンでテストを実行できます。この記事では、PostgreSQLContainer を使って UserRepository の CRUD を検証する結合テストを題材に、@Testcontainers と @Container の使い方、テーブル初期化のパターン、テストごとのデータクリーンアップを整理します。Docker が動く環境であれば特別な設定なしに使えるため、CI/CD パイプラインにも組み込みやすいのが大きな利点です。
使いどころ
PostgreSQL の JSONB カラムを使ったリポジトリクラスのテストを、本番と同じエンジンで検証する
Flyway で作成したスキーマの上で実データを挿入してクエリ結果をテストする
CI/CD パイプラインで外部 DB サーバーを用意せずに結合テストを行う
ユニーク制約やインデックス、トランザクション境界など本番 DB 固有の挙動を確認する
マイグレーション適用後の初期データや参照整合性を自動テストで継続確認する
コード例
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb").withUsername("testuser").withPassword("testpass");
record User(String id, String name, String email) {}
static class UserRepository {
private final String url, user, pass;
UserRepository(String url, String user, String pass) {
this.url = url; this.user = user; this.pass = pass;
}
Connection conn() throws SQLException { return DriverManager.getConnection(url, user, pass); }
void save(User u) throws SQLException {
try (var c = conn(); var ps = c.prepareStatement(
"INSERT INTO users(id,name,email) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name,email=EXCLUDED.email")) {
ps.setString(1, u.id()); ps.setString(2, u.name()); ps.setString(3, u.email()); ps.executeUpdate();
}
}
Optional<User> findById(String id) throws SQLException {
try (var c = conn(); var ps = c.prepareStatement("SELECT id,name,email FROM users WHERE id=?")) {
ps.setString(1, id);
try (var rs = ps.executeQuery()) {
return rs.next() ? Optional.of(new User(rs.getString(1), rs.getString(2), rs.getString(3))) : Optional.empty();
}
}
}
List<User> findAll() throws SQLException {
var list = new ArrayList<User>();
try (var c = conn(); var st = c.createStatement(); var rs = st.executeQuery("SELECT id,name,email FROM users ORDER BY id")) {
while (rs.next()) list.add(new User(rs.getString(1), rs.getString(2), rs.getString(3)));
}
return list;
}
}
static UserRepository repo;
@BeforeAll static void initSchema() throws SQLException {
repo = new UserRepository(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
try (var c = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
var st = c.createStatement()) {
st.execute("CREATE TABLE IF NOT EXISTS users(id VARCHAR(50) PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(200))");
}
}
@BeforeEach void clean() throws SQLException {
try (var c = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
var st = c.createStatement()) { st.execute("TRUNCATE TABLE users"); }
}
@Test @DisplayName("保存して取得できる")
void saveAndFind() throws SQLException {
repo.save(new User("U001", "田中太郎", "[email protected]"));
var found = repo.findById("U001");
assertTrue(found.isPresent());
assertEquals("田中太郎", found.get().name());
}
@Test @DisplayName("存在しない ID は空を返す")
void notFound() throws SQLException {
assertTrue(repo.findById("U999").isEmpty());
}
@Test @DisplayName("複数件を全件取得できる")
void findAll() throws SQLException {
repo.save(new User("U001", "田中", "[email protected]"));
repo.save(new User("U002", "鈴木", "[email protected]"));
assertEquals(2, repo.findAll().size());
}
}Version Coverage
Testcontainers 1.19+ は Java 17 を推奨。text block で SQL を読みやすく書け、record でテストデータを簡潔に表現できる。
// Java 17: @Testcontainers + text block で簡潔に
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
static final String CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(200)
)
""";
}Library Comparison
注意点
テスト実行環境に Docker が必要。Docker が動かない環境ではテストがスキップされる
コンテナの起動に数秒〜十数秒かかる。static フィールドで共有するか、テストスイートの分割を検討する
@Container を static にする場合、テスト間のデータ汚染を防ぐため @BeforeEach で TRUNCATE するか @AfterEach でロールバックする
PostgreSQLContainer のデフォルトイメージバージョンが固定されていない場合がある。本番と同じバージョンを明示指定する
testcontainers の依存は testImplementation に限定すること
CI 環境の Docker ソケット権限やメモリ制限で不安定になることがある。ローカルで通っても CI 設定は別途確認が必要
複数コンテナを組み合わせる統合テストは診断が難しくなる。まずは DB 単体で安定させてから広げる方が安全
FAQ
コンテナを static フィールドで共有し、Reusable Containers 機能を有効にすると起動コストを大幅に削減できます。
はい。MySQLContainer、OracleContainer など主要 DB のモジュールが公式に提供されています。
ランナーに Docker がプリインストールされているため追加設定なしで動作します。
はい。コンテナ起動後にマイグレーションを適用してからテストを流す構成が一般的です。アプリ起動時に自動適用させる方法でも構いません。
本番 DB との方言差や制約差が問題になりやすいなら有効です。一方で、単純なリポジトリの基本動作確認なら H2 のような軽量手段を先に使う方が回しやすいこともあります。