Database Testing in JUnit 6: H2 vs Real DB vs Containers

Every Java application talks to a database. Choosing the right testing strategy for that interaction determines how much confidence your test suite actually gives you. This guide compares the three main approaches β€” H2 in-memory, real database, and Testcontainers β€” with honest trade-offs, code examples for each, and a practical guide to combining them for maximum coverage at minimum cost.

The Three Approaches at a Glance

H2 In-MemoryReal Database (shared)Testcontainers
SpeedFastest (<1s startup)No startup costMedium (3–5s startup)
RealismLow β€” H2 β‰  PostgreSQLHigh β€” same as productionHighest β€” exact same engine
IsolationPerfect β€” fresh DB per runPoor β€” shared statePerfect β€” fresh container
CI friendlyYesRequires shared DB serverYes (Docker required)
Catches dialect bugsNoYesYes
Setup complexityMinimalMedium (infra required)Low (just Docker)

Option 1: H2 In-Memory Database

H2 is an embedded Java database that starts in milliseconds. Spring Boot’s @DataJpaTest uses H2 by default if it’s on the classpath.

<!-- H2 in-memory: auto-detected by @DataJpaTest -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.beans.factory.annotation.Autowired;

// @DataJpaTest uses H2 by default β€” no configuration needed
@DataJpaTest
@DisplayName("ProductRepository β€” H2 in-memory tests")
class ProductRepositoryH2Test {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private ProductRepository productRepository;

    @Test
    @DisplayName("Saving a product persists it and assigns an ID")
    void savingProductPersistsAndAssignsId() {
        Product product = new Product(null, "Laptop", 999.00, "Electronics");
        Product saved = entityManager.persistAndFlush(product);

        assertNotNull(saved.getId(), "Auto-generated ID must be assigned");
        assertEquals("Laptop", saved.getName());
    }

    @Test
    @DisplayName("Custom query findByCategory returns matching products")
    void findByCategoryReturnsMatchingProducts() {
        entityManager.persistAndFlush(new Product(null, "Laptop",     999.0, "Electronics"));
        entityManager.persistAndFlush(new Product(null, "Headphones",  79.0, "Electronics"));
        entityManager.persistAndFlush(new Product(null, "Notebook",     5.9, "Stationery"));

        List<Product> electronics = productRepository.findByCategory("Electronics");

        assertEquals(2, electronics.size());
    }
}

When to use H2: JPQL queries, basic CRUD verification, entity mapping validation, and anything that does not depend on database-specific SQL features or constraints.

When NOT to use H2: Native SQL queries, PostgreSQL-specific types (JSONB, arrays, UUID), CTEs, window functions, specific index behaviour, or ON CONFLICT (upsert) syntax.

Continue reading Database Testing in JUnit 6: H2 vs Real DB vs Containers

JUnit 6 with Testcontainers: Real Database Integration Testing

In-memory databases like H2 are fast and convenient but they hide real-world bugs β€” dialect differences, missing features, and different constraint handling mean tests pass on H2 but fail against your actual PostgreSQL or MySQL database. Testcontainers solves this by spinning up a real database (or any other service) in a Docker container for your tests. This guide shows you how to integrate Testcontainers with JUnit 6 for truly reliable database integration tests.

Prerequisites

  • Docker installed and running on the test machine (or CI agent)
  • Java 11+, JUnit 6, Maven or Gradle
  • Internet access on first run (to pull Docker images)

Dependencies

<!-- Testcontainers BOM: manages all module versions -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.20.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- Core Testcontainers + JUnit 5/6 integration -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- PostgreSQL module β€” swap for mysql, mariadb, mongodb, etc. -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- PostgreSQL JDBC driver -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Basic: Starting a PostgreSQL Container Per Test Class

import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;
import static org.junit.jupiter.api.Assertions.*;

// @Testcontainers: activates Testcontainers JUnit 6 extension
// It starts @Container fields before the tests and stops them after
@Testcontainers
@DisplayName("User Repository β€” PostgreSQL integration tests")
class UserRepositoryPostgresTest {

    // @Container: Testcontainers manages this container's lifecycle
    // 'static' = shared across ALL tests in this class (faster β€” starts once)
    // non-static = restarted before each test (slower but fully isolated)
    @Container
    static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass")
            .withInitScript("db/init-schema.sql"); // runs SQL on container start

    private Connection connection;
    private UserRepository userRepository;

    @BeforeEach
    void setUp() throws Exception {
        // Connect to the container using its dynamic port
        connection = DriverManager.getConnection(
            postgres.getJdbcUrl(),      // e.g. jdbc:postgresql://localhost:54321/testdb
            postgres.getUsername(),
            postgres.getPassword()
        );
        userRepository = new UserRepository(connection);
    }

    @AfterEach
    void tearDown() throws Exception {
        // Clean up test data between tests
        try (Statement stmt = connection.createStatement()) {
            stmt.execute("DELETE FROM users");
        }
        connection.close();
    }

    @Test
    @DisplayName("Saving a user persists it to PostgreSQL")
    void savingUserPersistsToPostgres() throws Exception {
        User user = new User(null, "[email protected]", "Alice");
        User saved = userRepository.save(user);

        assertNotNull(saved.getId(), "Saved user must have a generated ID");
        assertEquals("[email protected]", saved.getEmail());
    }

    @Test
    @DisplayName("Finding a user by email returns the correct record")
    void findByEmailReturnsCorrectUser() throws Exception {
        userRepository.save(new User(null, "[email protected]", "Bob"));

        Optional<User> found = userRepository.findByEmail("[email protected]");

        assertTrue(found.isPresent());
        assertEquals("Bob", found.get().getName());
    }

    @Test
    @DisplayName("Unique email constraint prevents duplicate users")
    void uniqueEmailConstraintPreventsduplicates() {
        userRepository.save(new User(null, "[email protected]", "Carol"));

        // Second save with same email should throw a constraint violation
        assertThrows(DataIntegrityViolationException.class,
            () -> userRepository.save(new User(null, "[email protected]", "Carol2")),
            "Duplicate email must be rejected by the database constraint"
        );
    }
}
Continue reading JUnit 6 with Testcontainers: Real Database Integration Testing

JUnit 6 with Mockito: Mocking, Spying, and Best Practices

Mockito and JUnit 6 are the most powerful duo in the Java testing toolkit. Mockito handles mock creation, stubbing, and verification while JUnit 6 orchestrates the test lifecycle. Together they let you test any unit in complete isolation, regardless of how many dependencies it has. This guide covers every Mockito feature you need β€” from basic mocking to advanced argument captors, spies, and common pitfalls to avoid.

Setup: Mockito with JUnit 6

<!-- Mockito Core + JUnit 5/6 integration -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>
<!-- Note: spring-boot-starter-test already includes mockito-junit-jupiter -->

Activate Mockito’s annotation processing by adding @ExtendWith(MockitoExtension.class) to your test class:

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class) // enables @Mock, @InjectMocks, @Captor, @Spy
class PaymentServiceTest { }

Creating Mocks: @Mock vs Mockito.mock()

@ExtendWith(MockitoExtension.class)
class MockCreationTest {

    // Annotation style β€” cleanest, preferred for fields
    @Mock
    private PaymentGateway paymentGateway;

    @Mock
    private NotificationService notificationService;

    // Programmatic style β€” useful when you need a mock inside a method
    @Test
    void programmaticMockCreation() {
        // Create a mock inside the test method
        UserRepository mockRepo = Mockito.mock(UserRepository.class);
        when(mockRepo.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));

        User found = mockRepo.findById(1L).orElseThrow();
        assertEquals("Alice", found.getName());
    }
}

Stubbing with when().thenReturn()

@ExtendWith(MockitoExtension.class)
class StubbingExamplesTest {

    @Mock private PaymentGateway paymentGateway;
    @InjectMocks private PaymentService paymentService;

    @Test
    @DisplayName("Successful payment returns a confirmation number")
    void successfulPaymentReturnsConfirmationNumber() {
        // Stub: when gateway.charge() is called with ANY double, return a confirmation
        when(paymentGateway.charge(anyDouble()))
            .thenReturn(new PaymentResult("CONF-12345", true));

        PaymentResult result = paymentService.processPayment(99.99);

        assertTrue(result.isSuccessful());
        assertEquals("CONF-12345", result.getConfirmationNumber());
    }

    @Test
    @DisplayName("Gateway failure propagates as PaymentException")
    void gatewayFailureThrowsPaymentException() {
        // Stub: throw an exception when the gateway is called
        when(paymentGateway.charge(anyDouble()))
            .thenThrow(new GatewayTimeoutException("Gateway timed out"));

        assertThrows(PaymentException.class,
            () -> paymentService.processPayment(99.99));
    }

    @Test
    @DisplayName("Multiple calls can return different values")
    void consecutiveCallsReturnDifferentValues() {
        // First call returns PENDING, second returns COMPLETED
        when(paymentGateway.checkStatus(anyString()))
            .thenReturn(PaymentStatus.PENDING)
            .thenReturn(PaymentStatus.COMPLETED);

        assertEquals(PaymentStatus.PENDING,   paymentGateway.checkStatus("TX-001"));
        assertEquals(PaymentStatus.COMPLETED, paymentGateway.checkStatus("TX-001"));
    }
}
Continue reading JUnit 6 with Mockito: Mocking, Spying, and Best Practices

Testing REST APIs with JUnit 6: MockMvc vs WebTestClient

Testing REST APIs is one of the most common tasks in any Spring Boot project. JUnit 6 supports two primary approaches: MockMvc for servlet-based (synchronous) APIs and WebTestClient for reactive (WebFlux) APIs β€” though WebTestClient can also test traditional Spring MVC applications. This guide covers both tools in depth with real request/response examples, JSON path assertions, and patterns for testing authentication, error responses, and pagination.

MockMvc vs WebTestClient: When to Use Which

MockMvcWebTestClient
TransportNo real HTTP β€” tests the servlet layer directlyReal or mock HTTP
Best forSpring MVC (traditional Servlet)WebFlux (reactive) or both
SpeedFastest β€” no networking overheadSlightly slower but more realistic
Fluent APIBuilder-style with matchersReactive, fluent chain
Works in @WebMvcTestβœ… Yes (auto-configured)βœ… Yes (with @AutoConfigureMockMvc)

Part 1: Testing REST APIs with MockMvc

Setup and Auto-Configuration

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;

@SpringBootTest
@AutoConfigureMockMvc // injects MockMvc with full security and filters
@DisplayName("Product API β€” MockMvc tests")
class ProductApiMockMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ProductRepository productRepository; // used for test data setup

    @BeforeEach
    void seedTestData() {
        productRepository.deleteAll();
        productRepository.saveAll(List.of(
            new Product(null, "Laptop",     999.00, "Electronics"),
            new Product(null, "Headphones",  79.00, "Electronics"),
            new Product(null, "Notebook",    5.99,  "Stationery")
        ));
    }

    @Test
    @DisplayName("GET /api/products returns 200 with all products")
    void getAllProductsReturns200() throws Exception {
        mockMvc.perform(get("/api/products")
                .accept("application/json"))
            .andDo(print()) // prints request + response to console for debugging
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json"))
            .andExpect(jsonPath("$").isArray())
            .andExpect(jsonPath("$.length()").value(3))
            .andExpect(jsonPath("$[0].name").value("Laptop"));
    }
}
Continue reading Testing REST APIs with JUnit 6: MockMvc vs WebTestClient

JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing

Spring Boot and JUnit 6 are the most widely used combination in the Java testing ecosystem. Together they give you a layered testing strategy β€” fast pure unit tests, focused slice tests that load only part of the Spring context, and full integration tests with a running application. This guide covers all three layers with complete, production-ready examples.

The Three Testing Layers

LayerAnnotationSpeedSpring ContextUse for
Unit@Test onlyVery fast (<10ms)NoneService logic, utilities, domain objects
Slice@WebMvcTest, @DataJpaTest, etc.Fast (1–5s)Partial β€” one layer onlyControllers, repositories, JSON serialization
Integration@SpringBootTestSlow (5–30s)Full application contextEnd-to-end flows, real DB, real HTTP

Setup: Spring Boot Test Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <!-- spring-boot-starter-test bundles:
         junit-jupiter, mockito-core, assertj-core,
         spring-test, hamcrest, jsonpath -->
</dependency>

Layer 1: Pure Unit Tests (No Spring Context)

For service and domain logic, avoid loading a Spring context entirely. Instantiate the class directly and mock dependencies with Mockito:

import org.junit.jupiter.api.*;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

// @ExtendWith(MockitoExtension.class): activates Mockito annotation processing
// No Spring context loaded β€” this test runs in milliseconds
@ExtendWith(MockitoExtension.class)
@DisplayName("OrderService β€” unit tests")
class OrderServiceTest {

    // @Mock creates a Mockito mock and injects it into @InjectMocks
    @Mock
    private OrderRepository orderRepository;

    @Mock
    private EmailService emailService;

    // @InjectMocks creates an instance with mocked dependencies injected
    @InjectMocks
    private OrderService orderService;

    @Test
    @DisplayName("Creating an order saves it and sends a confirmation email")
    void creatingOrderSavesItAndSendsConfirmationEmail() {
        // Arrange: define mock behaviour
        Order savedOrder = new Order(1L, "[email protected]", 99.99, OrderStatus.PENDING);
        when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

        // Act
        Order result = orderService.createOrder("[email protected]", 99.99);

        // Assert: verify result and interactions
        assertNotNull(result);
        assertEquals(OrderStatus.PENDING, result.getStatus());
        verify(orderRepository, times(1)).save(any(Order.class));
        verify(emailService,    times(1)).sendConfirmation("[email protected]");
    }

    @Test
    @DisplayName("Creating an order with negative total throws IllegalArgumentException")
    void negativeOrderTotalThrowsException() {
        assertThrows(IllegalArgumentException.class,
            () -> orderService.createOrder("[email protected]", -1.00));
        // Verify no repository or email interaction occurred
        verifyNoInteractions(orderRepository, emailService);
    }
}
Continue reading JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing

Advanced Extensions in JUnit 6: Creating Custom Testing Frameworks

The JUnit 6 extension model is not just for simple before/after hooks. At its most advanced, it lets you build entire custom testing frameworks on top of JUnit β€” with domain-specific annotations, automatic injection, retry logic, soft assertions, and test templates. This guide explores the most powerful extension patterns used by production-grade testing frameworks, with complete, runnable examples.

This post builds on JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step. Make sure you are comfortable with basic extension interfaces before diving in here.

Pattern 1: Composed Annotation Extensions

Combine multiple annotations and extensions into a single meta-annotation so test classes need only one annotation to get a full suite of capabilities:

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.*;
import java.lang.annotation.*;

/**
 * @DatabaseTest: a single annotation that applies:
 *  - @ExtendWith(DatabaseSetupExtension.class) β€” manages DB lifecycle
 *  - @ExtendWith(TransactionRollbackExtension.class) β€” rolls back after each test
 *  - @Tag("integration") β€” marks for integration test filtering
 *  - @Tag("database") β€” marks for database test filtering
 *  - @TestInstance(PER_CLASS) β€” shares one instance across methods
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(DatabaseSetupExtension.class)
@ExtendWith(TransactionRollbackExtension.class)
@Tag("integration")
@Tag("database")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public @interface DatabaseTest {
    // Custom attribute: which database profile to use
    String profile() default "test";
}

// DatabaseSetupExtension: opens connection before all, closes after all
class DatabaseSetupExtension implements BeforeAllCallback, AfterAllCallback {
    @Override
    public void beforeAll(ExtensionContext ctx) {
        System.out.println("[DB] Opening database connection");
        // Store connection in context store for tests to access
        ctx.getStore(ExtensionContext.Namespace.GLOBAL)
           .put("db.connection", createConnection());
    }
    @Override
    public void afterAll(ExtensionContext ctx) {
        System.out.println("[DB] Closing database connection");
    }
    private Object createConnection() { return "connection-placeholder"; }
}

// TransactionRollbackExtension: wraps each test in a transaction that rolls back
class TransactionRollbackExtension implements BeforeEachCallback, AfterEachCallback {
    @Override
    public void beforeEach(ExtensionContext ctx) {
        System.out.println("[TX] Starting transaction");
    }
    @Override
    public void afterEach(ExtensionContext ctx) {
        System.out.println("[TX] Rolling back transaction"); // ensures test isolation
    }
}

// Usage β€” one annotation gives you DB lifecycle + transaction rollback + tags
@DatabaseTest(profile = "integration")
class UserRepositoryTest {
    @Test
    void findByEmailReturnsCorrectUser() {
        // DB is set up, wrapped in a transaction that will roll back
        assertNotNull("result");
    }
}
Continue reading Advanced Extensions in JUnit 6: Creating Custom Testing Frameworks

Build Your Own JUnit 6 Test Engine (Advanced Guide)

Building your own JUnit 6 TestEngine is the ultimate act of framework mastery. It lets you run tests written in any format β€” YAML, XML, a custom DSL, plain text specifications β€” on the same JUnit Platform that runs your regular @Test methods. This guide builds a complete, working custom engine from scratch, step by step.

Before reading this, make sure you understand how the engine works internally β€” see JUnit 6 Internals: How the Test Engine Works first.

What We Will Build

We will build a CSV Test Engine that discovers .csv test files on the classpath, treats each row as a test case (input values + expected output), and runs them through a target method. This demonstrates every part of the TestEngine contract in a realistic, runnable example.

Step 1: Add the Platform Engine Dependency

<!-- JUnit Platform Engine SPI β€” provides TestEngine, TestDescriptor, etc. -->
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-engine</artifactId>
    <version>1.11.0</version>
    <scope>test</scope>
</dependency>

<!-- Commons CSV for parsing CSV test files -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
    <version>1.10.0</version>
    <scope>test</scope>
</dependency>
Continue reading Build Your Own JUnit 6 Test Engine (Advanced Guide)