コンテンツにスキップ

Java ユニットテスト

HTTP アプリを使う場合は API テスト と Playwright E2E テスト を参照してください。

分離方式の選択とアプリ経由のサンプルも参照してください。

Java の基本の依存関係を追加し、テスト用ソースディレクトリに次のファイルを保存して、./gradlew test または mvn test を実行します。

OrderTest.java
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 サーバーも起動します。

どちらのフレームワークでも、Java の基本設定に従って PostgreSQL JDBC ドライバーとプラットフォーム用の pgmem-native を追加します。

リポジトリの公開メソッドをテストする

Section titled “リポジトリの公開メソッドをテストする”

PgmemExtension でスキーマを一度準備します。DataSource パラメータはデフォルトでメソッドごとに新しいフォークを受け取り、書き込みは次のケースに残りません。@PgmemTest(fork = false) は読み取り専用メソッド用に準備済みフォークを共有します。リポジトリは注入された DataSource で作ります。作成済みプールの接続先は後から変更できません。この例の migrateAndSeed は orders(id, sku) テーブルを作ります。

OrderRepositoryTest.java
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));
}
}

データベースのライフサイクル

Section titled “データベースのライフサイクル”

テストごとにスキーマを変える場合は、新しいサーバーを起動します。同じスキーマとシードデータを使う場合は、PgmemExtension を 1 つ登録してテンプレートを一度だけ初期化し、テストクラスごとにフォークのスコープを選びます。

@Test
void schemaVariant() throws Exception {
try (Pgmem pg = Pgmem.builder().database("app").start();
Connection conn = DriverManager.getConnection(pg.template().jdbcUrl())) {
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();
}

ネストしたクラスは外側のクラスのプロセスを使い回します。

以下のどちらのスコープも、同じ準備済みテンプレートから始まります。クラス内のテストがすべて読み取り専用ならクラススコープを選び、書き込むテストには各テストに独立した複製を渡すメソッドスコープを使います。

読み取り専用のクラスで 1 つのフォークを共有する

Section titled “読み取り専用のクラスで 1 つのフォークを共有する”

パラメータで ForkScope.CLASS を指定するか、ビルダーの forkScope(ForkScope.CLASS) で既定にします。

@Test
void 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));
}
}

書き込むテストにはメソッドごとのフォーク

Section titled “書き込むテストにはメソッドごとのフォーク”

Fork、DataSource、@PgmemFork String のパラメータには、準備済みデータベースの新しい複製が渡され、テストの後で閉じられます。1 つのテストに複数のパラメータがあれば、同じフォークを共有します。

@Test
void 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));
}
}
@Test
void withUrl(@PgmemFork String jdbcUrl) {
var app = new App(jdbcUrl);
// ...
}
@Test
void 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);
}

template(name, prepare) で 2 つ目の準備済みデータベースを追加し、@PgmemFork("name") でそれを選びます。

@RegisterExtension
static PgmemExtension pg = PgmemExtension.builder()
.database("app")
.prepare(t -> migrate(t.jdbcUrl()))
.template("audit", t -> migrateAudit(t.jdbcUrl()))
.build();
@Test
void auditTrail(@PgmemFork("audit") DataSource audit) { /* ... */ }

メソッド単位のフォークなら JUnit の並列実行が可能です。クラス単位のフォークや @ShadowPg アプリでは、クラス内のメソッドを逐次実行します。maxForks(n) はテンプレートごとの同時フォーク数を制限し(既定はサーバーのメモリ上限の 4 分の 1 をフォーク 1 個のコストで割った値で、7GB の CI ランナーなら 28)、枠が埋まると新しいフォークは待ちます。forkTimeout(Duration) を指定すると、待機時間が期限を超えたとき pool_timeout で失敗します。省略した場合、待機に期限はありません。

import java.time.Duration;
@RegisterExtension
static 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 = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.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();