Java ユニットテスト
HTTP アプリを使う場合は API テスト と Playwright E2E テスト を参照してください。
分離方式の選択とアプリ経由のサンプルも参照してください。
JUnit 5 ですぐに試す
Section titled “JUnit 5 ですぐに試す”Java の基本の依存関係を追加し、テスト用ソースディレクトリに次のファイルを保存して、./gradlew test または mvn test を実行します。
import java.sql.Connection;import java.sql.DriverManager;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.RegisterExtension;import io.github.shibukawa.pgmem.junit5.PgmemExtension;import io.github.shibukawa.pgmem.junit5.TestDatabase;import static org.junit.jupiter.api.Assertions.assertEquals;
class OrderTest { @RegisterExtension static PgmemExtension pg = PgmemExtension.builder() .database("app") .prepare(server -> { try (Connection c = DriverManager.getConnection(server.jdbcUrl()); var statement = c.createStatement()) { statement.execute("CREATE TABLE orders(id int)"); } catch (java.sql.SQLException e) { throw new RuntimeException(e); } }).build();
@Test void createsOrder(TestDatabase db) throws Exception { try (Connection c = db.dataSource().getConnection(); var statement = c.createStatement()) { statement.execute("INSERT INTO orders VALUES (1)"); try (var rows = statement.executeQuery("SELECT count(*) FROM orders")) { rows.next(); assertEquals(1, rows.getInt(1)); } } }}prepare はクラスごとに一度走ります。各メソッドには新しいフォークを渡し、テスト終了後に extension が閉じます。TestDatabase から dataSource()、jdbcUrl()、dsn()、fork() を取得でき、同じテンプレートとスコープの複数パラメータは同じフォークを使います。読み取り専用クラスで共有する場合は、TestDatabase に @PgmemFork(scope = ForkScope.CLASS) を指定してください。
TestDatabase.reset() と Server.reset() はフォークを同じ接続先のまま戻します。seed 後に作った snapshot に戻す場合は Server.reset(snapshot, timeout) を使います。先に開いたトランザクションを終了させてください。接続 pool はアプリ側の fixture で管理し、所有するフォークが解放される前に閉じます。
アプリが管理するリポジトリをアノテーション 1 つでテストする
Section titled “アプリが管理するリポジトリをアノテーション 1 つでテストする”Spring Boot または Micronaut が通常どおりリポジトリと DataSource を作る場合は、各フレームワークの @ShadowPg を付けます。以下は OrderRepository がアプリの Bean として登録されている想定です。テストは本番コードと同じ Bean を使い、アプリ側に pgmem 固有の変更は不要です。db/schema.sql を src/test/resources に置きます。アプリが起動時にマイグレーションするなら schema は省略できます。初期データは seed = "db/seed.sql" で指定します。各メソッドは同じ準備済みデータから始まります。
@ShadowPg(schema = "db/schema.sql")class OrderRepositoryTest { @Autowired OrderRepository orders;
@Test void createsAndFindsAnOrder() throws SQLException { Order created = orders.create("book"); assertEquals(created, orders.findById(created.id())); }}testImplementation("io.github.shibukawa.pgmem:pgmem-spring:1.18.0") を追加し、io.github.shibukawa.pgmem.spring.ShadowPg をインポートします。このアノテーションは API テスト用にランダムポートの HTTP サーバーも起動します。
@ShadowPg(schema = "db/schema.sql")class OrderRepositoryTest { @Inject OrderRepository orders;
@Test void createsAndFindsAnOrder() throws SQLException { Order created = orders.create("book"); assertEquals(created, orders.findById(created.id())); }}testImplementation("io.github.shibukawa.pgmem:pgmem-micronaut:1.18.0") を追加し、io.github.shibukawa.pgmem.micronaut.ShadowPg をインポートします。
どちらのフレームワークでも、Java の基本設定に従って PostgreSQL JDBC ドライバーとプラットフォーム用の pgmem-native を追加します。
リポジトリの公開メソッドをテストする
Section titled “リポジトリの公開メソッドをテストする”PgmemExtension でスキーマを一度準備します。DataSource パラメータはデフォルトでメソッドごとに新しいフォークを受け取り、書き込みは次のケースに残りません。@PgmemTest(fork = false) は読み取り専用メソッド用に準備済みフォークを共有します。リポジトリは注入された DataSource で作ります。作成済みプールの接続先は後から変更できません。この例の migrateAndSeed は orders(id, sku) テーブルを作ります。
class OrderRepositoryTest { @RegisterExtension static PgmemExtension pg = PgmemExtension.builder() .database("app") .prepare(t -> migrateAndSeed(t.jdbcUrl())) .build();
@Test void createsAndFindsAnOrder(DataSource ds) throws SQLException { OrderRepository orders = new OrderRepository(ds); Order created = orders.create("book"); assertEquals(created, orders.findById(created.id())); }
@Test @PgmemTest(fork = false) void missingOrderReturnsNull(DataSource ds) throws SQLException { assertNull(new OrderRepository(ds).findById(-1)); }}record Order(long id, String sku) {}
final class OrderRepository { private final DataSource db; OrderRepository(DataSource db) { this.db = db; }
Order create(String sku) throws SQLException { try (Connection c = db.getConnection(); PreparedStatement st = c.prepareStatement( "INSERT INTO orders (sku) VALUES (?) RETURNING id")) { st.setString(1, sku); try (ResultSet rs = st.executeQuery()) { rs.next(); return new Order(rs.getLong(1), sku); } } }
Order findById(long id) throws SQLException { try (Connection c = db.getConnection(); PreparedStatement st = c.prepareStatement( "SELECT id, sku FROM orders WHERE id = ?")) { st.setLong(1, id); try (ResultSet rs = st.executeQuery()) { return rs.next() ? new Order(rs.getLong(1), rs.getString(2)) : null; } } }}データベースのライフサイクル
Section titled “データベースのライフサイクル”テストごとにスキーマを変える場合は、新しいサーバーを起動します。同じスキーマとシードデータを使う場合は、PgmemExtension を 1 つ登録してテンプレートを一度だけ初期化し、テストクラスごとにフォークのスコープを選びます。
テストごとに新しいサーバー
Section titled “テストごとに新しいサーバー”@Testvoid schemaVariant() throws Exception { try (Pgmem pg = Pgmem.builder().database("app").start(); Connection conn = DriverManager.getConnection(pg.template().jdbcUrl())) { conn.createStatement().execute(SCHEMA_V2); // ... }}@Testfun schemaVariant() { Pgmem.builder().database("app").start().use { pg -> DriverManager.getConnection(pg.template().jdbcUrl()).use { conn -> conn.createStatement().execute(SCHEMA_V2) } }}テンプレートを一度だけ初期化する
Section titled “テンプレートを一度だけ初期化する”拡張は static フィールドに登録します。JUnit はクラスの前に pgmem を起動し、テンプレートの Server を渡して prepare を一度だけ呼びます。ここでマイグレーションとシードデータを準備します。prepare が戻ると、そのデータベースをスナップショットにします。以下のフォークはこの準備済みスナップショットから作られるため、各テストメソッドで初期化を繰り返す必要がなく、テスト本体を振る舞いの確認に集中させられます。
class OrderRepositoryTest {
@RegisterExtension static PgmemExtension pg = PgmemExtension.builder() .database("app") .prepare(t -> { Flyway.configure().dataSource(t.jdbcUrl(), t.user(), null).load().migrate(); seed(t.jdbcUrl()); }) .build();}class OrderRepositoryTest {
companion object { @JvmField @RegisterExtension val pg: PgmemExtension = PgmemExtension.builder() .database("app") .prepare { t -> Flyway.configure().dataSource(t.jdbcUrl(), t.user(), null).load().migrate() seed(t.jdbcUrl()) } .build() }}ネストしたクラスは外側のクラスのプロセスを使い回します。
以下のどちらのスコープも、同じ準備済みテンプレートから始まります。クラス内のテストがすべて読み取り専用ならクラススコープを選び、書き込むテストには各テストに独立した複製を渡すメソッドスコープを使います。
読み取り専用のクラスで 1 つのフォークを共有する
Section titled “読み取り専用のクラスで 1 つのフォークを共有する”パラメータで ForkScope.CLASS を指定するか、ビルダーの forkScope(ForkScope.CLASS) で既定にします。
@Testvoid totals(@PgmemFork(scope = ForkScope.CLASS) DataSource ds) throws Exception { try (Connection conn = ds.getConnection(); ResultSet rs = conn.createStatement().executeQuery("SELECT count(*) FROM orders")) { rs.next(); assertEquals(10_000, rs.getInt(1)); }}@Testfun totals(@PgmemFork(scope = ForkScope.CLASS) ds: DataSource) { ds.connection.use { conn -> conn.createStatement().executeQuery("SELECT count(*) FROM orders").use { rs -> rs.next() assertEquals(10_000, rs.getInt(1)) } }}書き込むテストにはメソッドごとのフォーク
Section titled “書き込むテストにはメソッドごとのフォーク”Fork、DataSource、@PgmemFork String のパラメータには、準備済みデータベースの新しい複製が渡され、テストの後で閉じられます。1 つのテストに複数のパラメータがあれば、同じフォークを共有します。
@Testvoid cancelOrder(DataSource ds) throws Exception { new OrderRepository(ds).cancel(1); try (Connection conn = ds.getConnection(); ResultSet rs = conn.createStatement().executeQuery("SELECT status FROM orders WHERE id = 1")) { rs.next(); assertEquals("cancelled", rs.getString(1)); }}
@Testvoid withUrl(@PgmemFork String jdbcUrl) { var app = new App(jdbcUrl); // ...}@Testfun cancelOrder(ds: DataSource) { OrderRepository(ds).cancel(1) ds.connection.use { conn -> conn.createStatement().executeQuery("SELECT status FROM orders WHERE id = 1").use { rs -> rs.next() assertEquals("cancelled", rs.getString(1)) } }}DbUnit で検証する
Section titled “DbUnit で検証する”@Testvoid cancelOrder(Fork fork) throws Exception { new OrderRepository(fork.dataSource()).cancel(1);
IDatabaseConnection conn = new DatabaseConnection(DriverManager.getConnection(fork.jdbcUrl())); ITable actual = conn.createQueryTable("orders", "SELECT id, status FROM orders ORDER BY id"); ITable expected = new FlatXmlDataSetBuilder() .build(getClass().getResourceAsStream("/after_cancel.xml")).getTable("orders"); Assertion.assertEquals(expected, actual);}複数のテンプレート
Section titled “複数のテンプレート”template(name, prepare) で 2 つ目の準備済みデータベースを追加し、@PgmemFork("name") でそれを選びます。
@RegisterExtensionstatic PgmemExtension pg = PgmemExtension.builder() .database("app") .prepare(t -> migrate(t.jdbcUrl())) .template("audit", t -> migrateAudit(t.jdbcUrl())) .build();
@Testvoid auditTrail(@PgmemFork("audit") DataSource audit) { /* ... */ }メソッド単位のフォークなら JUnit の並列実行が可能です。クラス単位のフォークや @ShadowPg アプリでは、クラス内のメソッドを逐次実行します。maxForks(n) はテンプレートごとの同時フォーク数を制限し(既定はサーバーのメモリ上限の 4 分の 1 をフォーク 1 個のコストで割った値で、7GB の CI ランナーなら 28)、枠が埋まると新しいフォークは待ちます。forkTimeout(Duration) を指定すると、待機時間が期限を超えたとき pool_timeout で失敗します。省略した場合、待機に期限はありません。
import java.time.Duration;
@RegisterExtensionstatic PgmemExtension pg = PgmemExtension.builder() .database("app") .maxForks(4) .forkTimeout(Duration.ofSeconds(30)) .build();Gradle の maxParallelForks や Surefire の forkCount でテスト JVM を分けると、JVM ごとにバイナリとフォークプールが起動します。スナップショット、起動、接続待ちのタイムアウトは制限事項を参照してください。
ユニットテストで選ぶ並列実行の単位
Section titled “ユニットテストで選ぶ並列実行の単位”通常の PgmemExtension テストでは、メソッドごとのフォークとリポジトリなら並列実行できます。クラス単位のフォークや @ShadowPg のアプリはそのクラスで共有され、あるメソッドの前処理の reset が別メソッドのクエリと競合します。この場合はクラス内のメソッドを逐次実行し、独立したクラスを並列化します。JUnit Jupiter では src/test/resources/junit-platform.properties に次を設定します。
junit.jupiter.execution.parallel.enabled = truejunit.jupiter.execution.parallel.mode.default = same_threadjunit.jupiter.execution.parallel.mode.classes.default = concurrent並列実行する @ShadowPg クラスは、それぞれ別のアプリコンテキストとフォークを持たせます。static な DataSource や書き込み可能なクラス単位フォークをクラス間で共有しないでください。reset 前に接続を閉じるかコミットし、pgmem プロセスの増加に見合うメモリを確保します。
CLASS スコープの読み取りを保護する
Section titled “CLASS スコープの読み取りを保護する”Builder に .sharedReadOnly(true) を指定すると、CLASS スコープで注入する TestDatabase、DataSource、JDBC URL と、@PgmemTest(fork = false) が選ぶ共有接続先にガードを付けます。METHOD スコープは書き込み可能です。生の Fork 注入と database.fork() は書き込み可能な接続先を公開します。セッションの既定値によるガードなので、意図的に無効にすることは可能です。
@RegisterExtension static PgmemExtension pg = PgmemExtension.builder() .forkScope(ForkScope.CLASS).sharedReadOnly(true) .forkTimeout(Duration.ofSeconds(30)).build();