Java unit tests with JUnit 5
For an HTTP application, see API tests and Playwright E2E tests.
See the lifecycle guide and runnable application examples.
Quickstart with JUnit 5
Section titled “Quickstart with JUnit 5”Add the dependencies from Java basics, save this test in your test source directory, and run ./gradlew test or 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 runs once per class. Each method receives a fresh fork and the extension closes it after the test. TestDatabase gives you its dataSource(), jdbcUrl(), dsn() and fork(); multiple injected pgmem parameters for the same template and scope share that fork. Use @PgmemFork(scope = ForkScope.CLASS) on TestDatabase for a shared read-only class.
TestDatabase.reset() and Server.reset() restore a fork in place. Server.reset(snapshot, timeout) restores an explicitly seeded snapshot. End active transactions first. Keep your connection pools in application-owned fixtures and close them before the owning fork is released.
Test an application-managed repository with one annotation
Section titled “Test an application-managed repository with one annotation”If Spring Boot or Micronaut normally creates the repository and its DataSource, use the framework’s @ShadowPg. These examples assume OrderRepository is registered as an application bean. The test retrieves that same bean. The application needs no pgmem-specific changes. Put db/schema.sql in src/test/resources, or omit schema when the application runs its own startup migration. Add seed = "db/seed.sql" for initial rows. Each method starts from the same prepared data.
@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())); }}Add testImplementation("io.github.shibukawa.pgmem:pgmem-spring:1.18.0") and import io.github.shibukawa.pgmem.spring.ShadowPg. This annotation also starts a random-port HTTP server for API tests.
@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())); }}Add testImplementation("io.github.shibukawa.pgmem:pgmem-micronaut:1.18.0") and import io.github.shibukawa.pgmem.micronaut.ShadowPg.
Add the PostgreSQL JDBC driver and platform-specific pgmem-native dependency from Java basics for either framework.
Test a repository through its public methods
Section titled “Test a repository through its public methods”PgmemExtension prepares the schema once. A DataSource parameter receives a fresh fork by default, so a write in one method cannot affect the next. @PgmemTest(fork = false) shares a prepared fork for read-only methods. Create the repository with the injected DataSource; an existing pool cannot be redirected after construction. In this example, migrateAndSeed creates an orders(id, sku) table.
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; } } }}Database lifecycle strategies
Section titled “Database lifecycle strategies”Start a fresh server when a test needs a different schema. When tests share the same schema and seed data, register one PgmemExtension and initialize its template once; then choose a fork scope for each test class.
A fresh server per test
Section titled “A fresh server per test”@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) } }}Initialize one template once
Section titled “Initialize one template once”Register the extension in a static field. JUnit starts pgmem before the class and calls prepare once with the template Server; run migrations and seed data there. When prepare returns, the extension snapshots that database. The test forks below start from this prepared snapshot, so migrations and seed data stay out of each test method and the test body can focus on its behavior.
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() }}Nested classes reuse the outer class’s process.
Both scopes below start from this same prepared template. Choose class scope only when every test in the class is read-only; method scope gives each test its own writable copy.
Share one fork across a read-only class
Section titled “Share one fork across a read-only class”Ask for ForkScope.CLASS on the parameter, or make it the default with forkScope(ForkScope.CLASS) on the builder.
@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)) } }}Fork per method for tests that write
Section titled “Fork per method for tests that write”A Fork, DataSource or @PgmemFork String parameter gets a fresh copy of the prepared database, closed after the test. Several parameters in one test share one fork.
@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)) } }}Assert with DbUnit
Section titled “Assert with 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);}Several templates
Section titled “Several templates”template(name, prepare) adds a second prepared database, and @PgmemFork("name") selects it.
@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) { /* ... */ }Parallel runs
Section titled “Parallel runs”JUnit’s parallel execution is safe for method-scoped forks, because those forks are independent. Class-scoped forks and @ShadowPg applications need sequential methods. maxForks(n) caps live forks per template (default: a quarter of the server’s memory limit divided by a fork’s cost, 28 on a 7 GB CI runner). When all slots are occupied, a new fork waits. forkTimeout(Duration) makes that wait fail with pool_timeout; without it, the wait has no deadline.
import java.time.Duration;
@RegisterExtensionstatic PgmemExtension pg = PgmemExtension.builder() .database("app") .maxForks(4) .forkTimeout(Duration.ofSeconds(30)) .build();Every forked test JVM, from Gradle’s maxParallelForks or Surefire’s forkCount, starts its own binary and pool. See limits for snapshot, startup and connection-wait timeouts.
Choose the right parallel unit-test scope
Section titled “Choose the right parallel unit-test scope”For ordinary PgmemExtension tests, method-scoped forks and method-owned repositories can run in parallel. A class-scoped fork or @ShadowPg app is shared by that class: its reset before a method can race with another method’s query. Keep methods of such a class sequential and run independent classes concurrently. For JUnit Jupiter, put this in 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 = concurrentEach parallel @ShadowPg class must own its application context and fork. Do not share a static DataSource or a class-level writable fork across classes. Close or commit connections before a reset; increase JVM fork count only while memory can hold the resulting pgmem processes.
Guard CLASS-scoped reads
Section titled “Guard CLASS-scoped reads”Add .sharedReadOnly(true) to the builder to guard injected CLASS-scoped TestDatabase, DataSource and JDBC URLs, and shared targets selected with @PgmemTest(fork = false). METHOD-scoped connections stay writable. Raw Fork injection and database.fork() expose the writable endpoint. The guard is a session default, so callers can deliberately disable it.
@RegisterExtension static PgmemExtension pg = PgmemExtension.builder() .forkScope(ForkScope.CLASS).sharedReadOnly(true) .forkTimeout(Duration.ofSeconds(30)).build();