JUnit 6 is the first major JUnit release built for Java 17+ and the virtual-thread era. But JUnit alone is only the runner β modern Java test suites pair it with AssertJ for fluent assertions, Mockito 5 for mocking, and Testcontainers for real infrastructure in integration tests. This post ties all four together into a complete, runnable example you can drop into any Spring Boot or plain-Java project.
1. JUnit 6 β The Test Runner
JUnit 6 keeps the familiar annotations (@Test, @BeforeEach, @AfterEach) and adds first-class support for parameterized tests, display names, and nested test classes. The minimum Java version is 17.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
Calculator calc;
@BeforeEach
void setUp() {
calc = new Calculator(); // fresh instance before every test
}
@Test
@DisplayName("adds two positive numbers")
void addPositives() {
assertEquals(5, calc.add(2, 3));
}
@Test
@DisplayName("adding a negative number reduces the sum")
void addNegative() {
assertEquals(1, calc.add(3, -2));
}
// @CsvSource expands into one test case per row β no duplication
@ParameterizedTest
@CsvSource({"1,1,2", "2,3,5", "10,-4,6", "0,0,0"})
@DisplayName("add: parameterized cases")
void addParameterized(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
@Nested
@DisplayName("divide")
class DivideTests {
@Test
void divideByZeroThrows() {
assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
}
}
}
2. AssertJ β Fluent Assertions
AssertJ replaces JUnitβs built-in assertEquals/assertTrue with a chainable API. The real advantage is in failure messages: AssertJ prints the full actual value alongside the expectation, which saves time during debugging.
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class AssertJExample {
@Test
void collectionAssertions() {
List<String> names = List.of("ankur", "alex", "brian");
assertThat(names)
.hasSize(3)
.contains("ankur")
.doesNotContain("zoe")
.allMatch(n -> n.length() > 2) // every name longer than 2 chars
.startsWith("ankur"); // first element check
}
@Test
void exceptionAssertions() {
// Preferred over assertThrows β you can chain message checks directly
assertThatThrownBy(() -> { throw new IllegalStateException("boom"); })
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("boom");
}
@Test
void softAssertions() {
// Soft assertions collect ALL failures before reporting β no stopping at the first
assertSoftly(softly -> {
softly.assertThat(1 + 1).isEqualTo(2);
softly.assertThat("hello").startsWith("he");
softly.assertThat(List.of(1, 2, 3)).hasSize(3);
});
}
}
3. Mockito 5 β Mocking Collaborators
Mockito 5 uses the @ExtendWith(MockitoExtension.class) JUnit extension to auto-create mocks, validate unused stubs, and inject them. No MockitoAnnotations.openMocks(this) boilerplate needed.
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
@ExtendWith(MockitoExtension.class) // enables auto-injection and strict stub validation
class UserServiceTest {
@Mock UserRepository repo; // creates a mock automatically
@InjectMocks UserService service; // injects repo into service via constructor or field
@Test
void findsUserByEmail() {
// Arrange: stub the mock
when(repo.findByEmail("[email protected]"))
.thenReturn(new User(1L, "[email protected]"));
// Act
User result = service.lookup("[email protected]");
// Assert with AssertJ
assertThat(result.id()).isEqualTo(1L);
assertThat(result.email()).isEqualTo("[email protected]");
// Verify the repository was called exactly once with the right argument
verify(repo, times(1)).findByEmail("[email protected]");
verifyNoMoreInteractions(repo); // Mockito 5: strict β any extra calls fail the test
}
@Test
void throwsWhenUserNotFound() {
when(repo.findByEmail(anyString())).thenReturn(null);
assertThatThrownBy(() -> service.lookup("[email protected]"))
.isInstanceOf(UserNotFoundException.class);
}
}
4. Testcontainers β Real Infrastructure in Integration Tests
Testcontainers boots real Docker containers β Postgres, Redis, Kafka, etc. β for the duration of a test class and tears them down afterward. No more H2 compatibility gaps or shared test databases.
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.*;
import java.sql.*;
import static org.assertj.core.api.Assertions.*;
@Testcontainers
class PostgresIT {
// static: one container shared across all tests in this class (faster)
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
@Test
void canInsertAndRead() throws Exception {
// Testcontainers resolves the dynamic port automatically
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword());
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE users (id INT, name TEXT)");
stmt.execute("INSERT INTO users VALUES (1, 'ankur')");
ResultSet rs = stmt.executeQuery("SELECT name FROM users WHERE id = 1");
rs.next();
assertThat(rs.getString("name")).isEqualTo("ankur");
}
}
}
// Spring Boot integration: wire dynamic container properties into Spring context
@SpringBootTest
@Testcontainers
class SpringPostgresIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void postgresProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired UserRepository repo;
@Test
void repoRoundTrip() {
repo.save(new User(null, "[email protected]"));
assertThat(repo.findAll()).hasSize(1);
}
}
5. Testing Hibernate 7 with Testcontainers: Multiple Databases
Hibernate 7 brought stricter JPQL validation, the full Jakarta EE namespace switch, and improved batch-flush behaviour β but none of those changes mean anything if your integration tests run against H2 while production runs PostgreSQL, MySQL, or Oracle. H2 silently accepts SQL dialects and schema constructs that every production engine rejects, giving you false confidence. The right fix is a parameterised Testcontainers test that boots a real container for each target engine and wires a fresh Hibernate 7 SessionFactory to each one automatically.
import jakarta.persistence.*;
import org.hibernate.cfg.Configuration;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.testcontainers.containers.*;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.*;
@Testcontainers
class Hibernate7MultiDbIT {
// Minimal entity β Hibernate 7 requires the jakarta.persistence namespace (not javax)
@Entity
@Table(name = "products")
static class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long productId;
String productName;
BigDecimal unitPrice;
Product() {}
Product(String productName, BigDecimal unitPrice) {
this.productName = productName;
this.unitPrice = unitPrice;
}
}
// Stream of real containers β JUnit 6 runs the test once per element
static Stream<JdbcDatabaseContainer<?>> databases() {
return Stream.of(
new PostgreSQLContainer<>("postgres:16-alpine"),
new MySQLContainer<>("mysql:8.3"),
new OracleContainer("gvenzl/oracle-xe:21-slim-faststart") // faststart cuts cold start to ~18 s
);
}
@ParameterizedTest(name = "Hibernate 7 on {0}")
@MethodSource("databases")
void canPersistAndLoad(JdbcDatabaseContainer<?> db) {
db.start(); // each parameterised run gets its own isolated container
try {
// Wire a Hibernate 7 SessionFactory to this container's live JDBC URL
var sessionFactory = new Configuration()
.setProperty("hibernate.connection.url", db.getJdbcUrl())
.setProperty("hibernate.connection.username", db.getUsername())
.setProperty("hibernate.connection.password", db.getPassword())
.setProperty("hibernate.hbm2ddl.auto", "create-drop") // fresh schema per run
.addAnnotatedClass(Product.class)
.buildSessionFactory();
// Write in one session, read back in a separate session β proves data reached the store
Long savedId;
try (var writeSession = sessionFactory.openSession()) {
var tx = writeSession.beginTransaction();
var product = new Product("Keyboard", new BigDecimal("79.99"));
writeSession.persist(product); // Hibernate 7 removed save() β persist() is the JPA-standard replacement
tx.commit();
savedId = product.productId; // IDENTITY id is populated on insert
}
try (var readSession = sessionFactory.openSession()) {
var loaded = readSession.get(Product.class, savedId);
assertThat(loaded).isNotNull();
assertThat(loaded.productName).isEqualTo("Keyboard");
assertThat(loaded.unitPrice).isEqualByComparingTo("79.99");
}
sessionFactory.close();
} finally {
db.stop(); // container torn down cleanly after each parameterised case
}
}
}
@MethodSource("databases") tells JUnit 6 to invoke canPersistAndLoad once per element in the stream β once for PostgreSQL 16, once for MySQL 8.3, and once for Oracle XE 21. Each invocation receives its own live container, a fresh create-drop schema, and an isolated SessionFactory, so a schema-compatibility failure on Oracle does not mask a passing Postgres result. Hibernate 7 derives the correct dialect automatically from the JDBC URL for all three engines, so no explicit hibernate.dialect property is needed. The deliberate two-session pattern β write in one, read back in another β confirms that data was actually flushed to the store rather than just surviving in the first-level cache.
For Spring Boot projects, replace the manual Configuration block with @DynamicPropertySource and keep the databases() factory method unchanged. Annotate the test class with both @SpringBootTest and @Testcontainers, and Spring Boot’s Hibernate auto-configuration picks up the spring.datasource.url, spring.datasource.username, and spring.datasource.password properties injected dynamically from each container. The parameterisation logic is identical to the plain-Hibernate approach above.
Two production-readiness details worth noting. First, Oracle XE takes 60β90 seconds to start cold β use the faststart image tag (gvenzl/oracle-xe:21-slim-faststart), which pre-initialises the container filesystem and cuts startup to under 20 seconds. In CI, add testcontainers.reuse.enable=true to .testcontainers.properties so the Oracle container stays warm across pipeline stages in the same job. Second, GenerationType.IDENTITY is safe across all three engines from Hibernate 7 onwards β Oracle XE 21 now supports IDENTITY columns natively, so cross-database portability no longer requires falling back to the SEQUENCE or TABLE generation strategies. Third, watch for reserved-word collisions: column names such as value, name, and order are accepted by H2 but require quoting on Oracle or MySQL. With this multi-engine test in place, those clashes surface immediately at the create-drop phase β not in a production deployment.
How the Code Works
- JUnit 6 runs each
@Testmethod in a fresh test instance by default (lifecyclePER_METHOD).@BeforeEachreinitialises shared state before every test, preventing leakage between cases.@ParameterizedTest+@CsvSourceexpands into one JUnit test case per CSV row with no code duplication. - AssertJ chains assertions on a single
assertThat()subject so the intent reads like English. On failure it prints the full actual value alongside the expected one, which is far more useful than JUnitβs built-in βexpected: 5 but was: 3β. Soft assertions (assertSoftly) collect every failure before reporting β useful for validating multiple fields of a complex object. - Mockito 5 with
@ExtendWith(MockitoExtension.class)auto-creates mocks declared with@Mockand injects them. Strict stub validation (the Mockito 5 default) fails the test if a stub is set up but never called β catches dead code in test setup. - Testcontainers boots a real Postgres in Docker for the test class. Network ports and credentials are resolved dynamically, so the tests never hard-code connection strings. The
staticfield shares one container across all tests in the class, which is far faster than starting a new container per test method. - Hibernate 7 + Testcontainers (multi-database) uses
@MethodSourceto feed three differentJdbcDatabaseContainerinstances into a single parameterised test. Each run builds its ownSessionFactoryfrom the container’s live JDBC URL β no dialect property needed, no H2 compromises. The two-session round-trip (write then read in a separate session) proves data reached the store, not just the first-level cache.
AI Prompts You Can Use
Paste these into Claude, ChatGPT, or Cursor with your source file attached:
Prompt 1 β Generate a JUnit 6 Test Class
What it does: Generates a complete JUnit 6 test class for an attached Java class. Uses @DisplayName for readable test names, @ParameterizedTest for data-driven cases, and AssertJ fluent assertions throughout. Targets one test per behaviour rather than one per method.
When to use it: When starting from scratch on an untested class and want a solid first draft in seconds.
Generate a JUnit 6 test class for the attached Java class. Use @DisplayName for readability, @ParameterizedTest with @CsvSource where inputs vary, and AssertJ fluent assertions instead of JUnit's assertEquals. Aim for one test per behaviour, not one per method. Include edge cases and a test for the expected exception.
Prompt 2 β Convert Hamcrest to AssertJ
What it does: Converts every Hamcrest assertThat(..., is(...)), hasItem, containsString, etc. to the AssertJ equivalent. Chains multiple assertions on the same subject into a single assertThat() call where possible.
When to use it: Migrating a legacy test suite that used Hamcrest (common in pre-JUnit 5 projects) to modern AssertJ style.
Convert every assertThat(...) using Hamcrest matchers in this file to AssertJ equivalents. Prefer chained assertions over multiple statements where the subject is the same. Replace hamcrest imports with org.assertj.core.api.Assertions.*
Prompt 3 β Mock Collaborators with Mockito 5
What it does: Writes a Mockito 5 test for a service class. Mocks all external collaborators, stubs return values with when/thenReturn, verifies interactions with verify(), and uses verifyNoMoreInteractions for strict stub checking. Uses @ExtendWith(MockitoExtension.class) and @InjectMocks.
When to use it: Writing unit tests for a service class that has injected dependencies you do not want to instantiate for real.
Write a Mockito 5 unit test for this service class. Mock all external collaborators with @Mock, stub return values with when/thenReturn, and verify interactions with verify(). Use @ExtendWith(MockitoExtension.class) and @InjectMocks. Add verifyNoMoreInteractions where appropriate.
Prompt 4 β Add Testcontainers for Integration Tests
What it does: Converts an H2 in-memory integration test to use a real Postgres 16 Testcontainer. Wires in the dynamic JDBC URL via @DynamicPropertySource for Spring Boot tests. Adds a static @Container field to share one container across all tests in the class for speed.
When to use it: When H2 compatibility gaps are causing test/production behaviour divergence, or when you need to test DB-specific features (JSON columns, partitioning, generated columns) that H2 does not support.
Convert this H2 in-memory integration test to use Testcontainers with a real Postgres 16 Alpine container. Wire up dynamic properties via @DynamicPropertySource if this is a Spring Boot test. Use a static @Container field to share the container across all test methods in the class.
Prompt 5 β Flaky Test Diagnosis
What it does: Reviews a test for the four most common flakiness root causes: time-dependent assertions (wall-clock comparisons), shared mutable state between tests, implicit ordering dependencies between test methods, and missing Awaitility or polling for async code. Proposes concrete fixes for each issue found.
When to use it: When a test fails intermittently in CI but passes locally β the most frustrating testing problem. Paste in the full test class, not just the failing method.
Review this test class for flakiness. Check for: (1) time-dependent assertions using wall-clock time, (2) shared mutable state between test methods, (3) implicit ordering dependencies between tests, (4) missing Awaitility or polling for async operations. For each issue found, propose a concrete fix.
See Also
- JUnit 6 vs Spock vs TestNG: Best Testing Framework for Java?
- JUnit 6 vs Mockito: Roles, Differences, and Integration
- Performance Testing Using JUnit 6
- How to Fix Flaky Tests in JUnit 6
- Debugging JUnit 6 Tests: Fix Failures Like a Pro
- 10 AI Prompts to Generate JUnit 6 Tests for New Projects
- 10 AI Prompts to Debug and Fix JUnit 6 Test Failures
- Java 21 to Java 25 LTS: Every Feature You Actually Need to Know
- Virtual Threads vs Platform Threads β Benchmarks and Code
- Java 21 to Java 25 Upgrade Guide (with AI Prompts)
FAQs
Does JUnit 6 require Java 17?
Yes β Java 17 is the minimum runtime for JUnit 6. If you are still on Java 11 or earlier, stay on JUnit 5.x, which continues to receive maintenance releases. JUnit 5 tests are compatible with JUnit 6βs engine in mixed-version projects.
Can I mix AssertJ and JUnit asserts in the same project?
Technically yes, but it hurts readability. Pick one assertion library per project (AssertJ is the recommended choice for new code) and enforce it with a Checkstyle or ArchUnit rule that forbids org.junit.jupiter.api.Assertions.* static imports in test files.
Will Testcontainers slow my CI pipeline?
The first run is slow because Docker must pull the image. Subsequent runs reuse the image layer cache and are much faster. Locally, use @Container with reuse = true (and a .testcontainers.properties file with testcontainers.reuse.enable=true) to keep the container alive between test runs.
What is the difference between @Mock and @Spy in Mockito 5?
@Mock creates a complete stub β every method returns a default value until stubbed. @Spy wraps a real object β unstubbed methods call the real implementation. Use @Mock for external dependencies you want to control completely; use @Spy when you want to test a real class but override one or two methods.
Conclusion
JUnit 6 + AssertJ + Mockito 5 + Testcontainers is the default Java test stack for 2026. Once you adopt all four, your tests read cleanly, isolate properly, and run against real infrastructure without CI flakiness. The five AI prompts above cover the most common tasks: generating tests, converting old assertion styles, adding mocks, migrating to real containers, and diagnosing flaky tests.