Skip to content

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.

Add the dependencies from Java basics, save this test in your test source directory, and run ./gradlew test or 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 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.

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.

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));
}
}

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.

@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);
// ...
}
}

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();
}

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.

Ask for ForkScope.CLASS on the parameter, or make it the default with forkScope(ForkScope.CLASS) on the builder.

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

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.

@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) adds a second prepared database, and @PgmemFork("name") selects it.

@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’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;
@RegisterExtension
static 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.

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 = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent

Each 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.

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();