Tag Archives: Java

Test Reporting in JUnit 6: Allure vs Surefire vs Custom Reports

Raw JUnit XML output from Maven Surefire is functional but not human-friendly. Test reporting transforms those results into meaningful dashboards that developers, QA engineers, and product owners can navigate, filter, and act on. This guide compares the three most popular reporting options for JUnit 6 projects β€” Maven Surefire Reports, Allure Framework, and custom HTML reports β€” with complete setup, screenshots of what each produces, and guidance on which to choose.

Comparison at a Glance

Maven Surefire ReportAllure FrameworkCustom HTML
Setup effortMinimal (plugin only)Medium (annotation + CLI)High (build it yourself)
Report qualityBasicRich, interactiveFully custom
History/trendsNoYes (with Allure server)Optional (manual build)
Annotations in testsNone needed@Step, @DescriptionCustom
CI integrationDirect (XML output)Allure GitHub ActionUpload artifact
Best forQuick feedback, simple buildsQA-facing dashboards, large suitesBranded internal portals

Option 1: Maven Surefire HTML Report

The simplest option. Maven Surefire generates XML reports automatically. Add the surefire-report plugin to generate an HTML summary:

<!-- Add to your reporting section in pom.xml -->
<reporting>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-report-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</reporting>
# Generate surefire HTML report after running tests
mvn surefire-report:report

# Or generate as part of the site lifecycle
mvn test site

# Output: target/site/surefire-report.html
Surefire Report Summary

Test Suite: OrderServiceTest
  Tests: 12  Errors: 0  Failures: 0  Skipped: 0  Success Rate: 100%  Time: 0.41s

Test Suite: OrderRepositoryIT
  Tests: 6   Errors: 0  Failures: 1  Skipped: 0  Success Rate: 83%   Time: 3.21s
  FAILED: findByEmail_withNullEmail_throwsException (0.04s)
    java.lang.AssertionError: Expected IllegalArgumentException but no exception was thrown
Continue reading Test Reporting in JUnit 6: Allure vs Surefire vs Custom Reports

Code Coverage with JaCoCo and JUnit 6: Complete Setup

Writing tests is only the first step. Knowing how much of your code those tests actually exercise β€” and which parts remain untested β€” requires a code coverage tool. JaCoCo (Java Code Coverage) is the de facto standard for Java projects and integrates seamlessly with JUnit 6, Maven, Gradle, and CI pipelines. This guide walks you through the complete JaCoCo setup, report interpretation, and coverage enforcement with real configuration examples.

What JaCoCo Measures

MetricWhat it countsWhen it matters
Line coverageExecutable source lines touched by testsBasic coverage baseline
Branch coverageif/else, switch, ternary branches takenLogic-heavy code
Method coverageMethods called at least onceDead code detection
Class coverageClasses instantiated at least onceUnused class detection
Instruction coverageJVM bytecode instructions executedMost granular metric
Complexity coverageCyclomatic paths through codeComplex business logic

Maven Setup: JaCoCo Plugin

<build>
    <plugins>
        <!-- JaCoCo Maven Plugin -->
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.12</version>
            <executions>

                <!-- Execution 1: prepare-agent
                     Attaches JaCoCo Java agent BEFORE tests run.
                     The agent instruments bytecode at runtime to track coverage. -->
                <execution>
                    <id>prepare-agent</id>
                    <goals><goal>prepare-agent</goal></goals>
                </execution>

                <!-- Execution 2: report
                     Generates HTML, XML, and CSV reports AFTER tests complete.
                     Output: target/site/jacoco/index.html -->
                <execution>
                    <id>generate-report</id>
                    <phase>verify</phase>
                    <goals><goal>report</goal></goals>
                </execution>

                <!-- Execution 3: check
                     Enforces minimum coverage thresholds.
                     Fails the build if coverage drops below the defined rules. -->
                <execution>
                    <id>enforce-coverage</id>
                    <phase>verify</phase>
                    <goals><goal>check</goal></goals>
                    <configuration>
                        <rules>
                            <rule>
                                <element>BUNDLE</element>  <!-- entire project -->
                                <limits>
                                    <!-- Require at least 80% line coverage -->
                                    <limit>
                                        <counter>LINE</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.80</minimum>
                                    </limit>
                                    <!-- Require at least 70% branch coverage -->
                                    <limit>
                                        <counter>BRANCH</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.70</minimum>
                                    </limit>
                                </limits>
                            </rule>
                        </rules>
                    </configuration>
                </execution>

            </executions>
        </plugin>
    </plugins>
</build>
Continue reading Code Coverage with JaCoCo and JUnit 6: Complete Setup

Running JUnit 6 Tests in CI/CD Pipelines (GitHub Actions, Jenkins)

Running tests locally is only half the story. The real value of automated tests comes when they run automatically on every push, pull request, and merge β€” in a CI/CD pipeline. This guide covers complete, production-ready pipeline configurations for both GitHub Actions and Jenkins, including caching, parallel stages, test result publishing, coverage gates, and Testcontainers support.

Why CI/CD for JUnit 6 Tests Matters

  • Catch regressions before merge β€” no broken code reaches main
  • Enforce coverage thresholds β€” automated gates prevent coverage drops
  • Parallelise expensive tests β€” cut pipeline time from 20 minutes to 5
  • Publish reports β€” test results and coverage visible in every PR
  • Consistent environment β€” no more "works on my machine"

Part 1: GitHub Actions

Basic Pipeline: Build, Test, Report

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      # Cache Maven dependencies β€” speeds up subsequent runs significantly
      - name: Cache Maven packages
        uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
          restore-keys: ${{ runner.os }}-maven-

      # Run unit tests on every push (fast)
      - name: Run unit tests
        run: mvn test -Dgroups=unit --batch-mode

      # Run integration tests only on PRs to main (slower)
      - name: Run integration tests
        if: github.event_name == 'pull_request'
        run: mvn verify -Dgroups=integration --batch-mode

      # Publish JUnit XML results as a GitHub Check
      - name: Publish test results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: target/surefire-reports/**/*.xml

      # Upload JaCoCo HTML coverage report as artifact
      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: target/site/jacoco/

Advanced Pipeline: Parallel Matrix + Coverage Gate

# .github/workflows/full-ci.yml
name: Full CI with Parallel Stages

on:
  pull_request:
    branches: [ main ]

jobs:
  # Stage 1: Unit tests (fast, runs first)
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }
      - uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
      - name: Unit tests with coverage
        run: mvn test -Dgroups=unit jacoco:report --batch-mode
      - name: Check coverage threshold (min 80%)
        run: mvn jacoco:check --batch-mode
      - uses: actions/upload-artifact@v4
        with: { name: unit-coverage, path: target/site/jacoco/ }

  # Stage 2: Integration tests (parallel with unit tests above)
  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }
      - uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
      # Docker is pre-installed on ubuntu-latest β€” Testcontainers works out of the box
      - name: Integration tests with Testcontainers
        run: mvn verify -Dgroups=integration -DexcludedGroups=unit --batch-mode
      - uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with: { files: target/failsafe-reports/**/*.xml }

  # Stage 3: Deploy (only if both test stages pass)
  deploy:
    needs: [ unit-tests, integration-tests ]  # waits for both parallel jobs
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to staging
        run: echo "Deploying to staging..."
Continue reading Running JUnit 6 Tests in CI/CD Pipelines (GitHub Actions, Jenkins)

Test Data Management Strategies in JUnit 6 Projects

Poor test data management is one of the leading causes of flaky, brittle, and hard-to-maintain test suites. Tests that rely on globally shared, manually maintained database records break the moment someone changes the seed data. This guide covers every test data strategy used in production JUnit 6 projects β€” from simple inline data to sophisticated builders, SQL scripts, and Faker-powered generators β€” with complete examples for each layer of the testing pyramid.

The Core Principle: Tests Own Their Data

Every test should create the data it needs, verify its assertions, and leave no trace behind. Tests that depend on data set up by other tests, global seed scripts, or manual database inserts become order-dependent and fragile. The golden rule:

  • βœ… Each test creates its own data in @BeforeEach or inline in the test body
  • βœ… Each test cleans up its data in @AfterEach or via transaction rollback
  • ❌ Never rely on data created by another test method
  • ❌ Never rely on data that is manually inserted into a shared database

Strategy 1: Inline Test Data

For simple unit tests, create test objects directly inside the test method. This is the most readable and self-contained approach:

@Test
@DisplayName("Applying a 10% discount reduces order total correctly")
void applyingTenPercentDiscountReducesTotal() {
    // Inline test data β€” no setup method needed
    Order order = new Order(
        1L,
        "[email protected]",
        100.00,
        OrderStatus.PENDING
    );
    Discount discount = new Discount(DiscountType.PERCENTAGE, 10.0);

    // Act
    double discountedTotal = discountService.apply(order, discount);

    // Assert
    assertEquals(90.00, discountedTotal, 0.001,
        "10% of 100.00 should give 90.00");
}

Strategy 2: Object Mother Pattern

When many tests need the same object in a standard state, the Object Mother provides factory methods that return pre-built instances. This eliminates repetition and makes tests more readable:

package com.example.testdata;

/**
 * OrderMother: factory for standard Order test objects.
 * Centralises test data creation β€” one place to update when Order changes.
 */
public class OrderMother {

    // A valid, fully-populated Order ready to process
    public static Order validPendingOrder() {
        return Order.builder()
            .id(1L)
            .customerEmail("[email protected]")
            .totalAmount(99.99)
            .status(OrderStatus.PENDING)
            .createdAt(LocalDateTime.now())
            .build();
    }

    // An order that has already been completed
    public static Order completedOrder() {
        return validPendingOrder().toBuilder()
            .id(2L)
            .status(OrderStatus.COMPLETED)
            .completedAt(LocalDateTime.now())
            .build();
    }

    // An order with an unusually large total β€” for boundary testing
    public static Order highValueOrder() {
        return validPendingOrder().toBuilder()
            .id(3L)
            .totalAmount(10_000.00)
            .build();
    }

    // An order with null email β€” for negative/validation testing
    public static Order orderWithNullEmail() {
        return validPendingOrder().toBuilder()
            .customerEmail(null)
            .build();
    }
}

// Usage: clean, readable, no duplication
class OrderServiceTest {

    @Test
    void completingAValidOrderChangesStatusToCompleted() {
        Order order = OrderMother.validPendingOrder();
        orderService.complete(order);
        assertEquals(OrderStatus.COMPLETED, order.getStatus());
    }

    @Test
    void highValueOrdersRequireManagerApproval() {
        Order order = OrderMother.highValueOrder();
        assertTrue(orderService.requiresManagerApproval(order));
    }
}
Continue reading Test Data Management Strategies in JUnit 6 Projects

Testing Microservices with JUnit 6: Integration, Contract & E2E

Testing microservices is fundamentally different from testing a monolith. You have distributed state, network boundaries, independent deployments, and inter-service contracts that can drift apart silently. A solid microservices testing strategy with JUnit 6 operates at three distinct levels: integration tests within a single service, contract tests between services, and end-to-end tests across the entire system. This guide covers all three with concrete, production-grade examples.

The Microservices Testing Pyramid

Level 1: Integration Tests Within a Single Service

Integration tests for a microservice verify that all layers of the service work together correctly β€” controller, service, repository, and database β€” but in isolation from other services. External service calls are stubbed using WireMock.

<!-- WireMock: HTTP stub server for testing external service calls -->
<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-jetty12</artifactId>
    <version>3.9.1</version>
    <scope>test</scope>
</dependency>
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import org.junit.jupiter.api.extension.RegisterExtension;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@DisplayName("Order Service β€” integration test with stubbed inventory service")
class OrderServiceIntegrationTest {

    // Testcontainers: real PostgreSQL for the order service’s own database
    @Container
    static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    // WireMock: stub server simulating the external Inventory Service
    @RegisterExtension
    static WireMockExtension inventoryServiceStub = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // Point the order service’s datasource to the Testcontainers DB
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);

        // Point the order service’s inventory client to the WireMock stub
        registry.add("inventory.service.url",
            () -> inventoryServiceStub.baseUrl());
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    @DisplayName("Creating order succeeds when inventory has sufficient stock")
    void creatingOrderSucceedsWhenStockAvailable() {
        // Stub: inventory service confirms stock is available
        inventoryServiceStub.stubFor(
            WireMock.get(WireMock.urlPathEqualTo("/api/inventory/LAPTOP-01"))
                .willReturn(WireMock.aResponse()
                    .withHeader("Content-Type", "application/json")
                    .withBody("{"productId":"LAPTOP-01","available":true,"quantity":50}")
                    .withStatus(200))
        );

        // Act: call the order service
        CreateOrderRequest request =
            new CreateOrderRequest("[email protected]", "LAPTOP-01", 1);
        ResponseEntity<OrderDto> response =
            restTemplate.postForEntity("/api/orders", request, OrderDto.class);

        // Assert
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertEquals(OrderStatus.CONFIRMED, response.getBody().getStatus());

        // Verify the order service actually called the inventory service
        inventoryServiceStub.verify(1,
            WireMock.getRequestedFor(
                WireMock.urlPathEqualTo("/api/inventory/LAPTOP-01")));
    }

    @Test
    @DisplayName("Creating order fails when inventory has no stock")
    void creatingOrderFailsWhenOutOfStock() {
        inventoryServiceStub.stubFor(
            WireMock.get(WireMock.urlPathEqualTo("/api/inventory/SOLD-OUT-01"))
                .willReturn(WireMock.aResponse()
                    .withBody("{"productId":"SOLD-OUT-01","available":false,"quantity":0}")
                    .withStatus(200))
        );

        CreateOrderRequest request =
            new CreateOrderRequest("[email protected]", "SOLD-OUT-01", 1);
        ResponseEntity<ErrorDto> response =
            restTemplate.postForEntity("/api/orders", request, ErrorDto.class);

        assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
        assertTrue(response.getBody().getMessage().contains("out of stock"));
    }
}
Continue reading Testing Microservices with JUnit 6: Integration, Contract & E2E

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