Test Pyramid vs Test Trophy: What Actually Works in Production

The Test Pyramid has guided software testing strategy for over a decade. But in modern software — especially microservices, APIs, and React frontends — some teams find a different shape, the Test Trophy, better reflects where testing value actually lives. This guide explains both models honestly, examines where each works and fails, and gives you a practical framework for choosing the right mix for your specific project with JUnit 6.

The Test Pyramid

Mike Cohn introduced the Test Pyramid in 2009. The model has three layers:

The Pyramid’s core message: Write lots of fast unit tests, fewer integration tests, and very few E2E tests. This minimises test suite cost (time + maintenance) while maximising coverage.

The Test Trophy

Kent C. Dodds proposed the Test Trophy in 2018. Its shape places integration tests at the centre, with static analysis at the base:

The Trophy’s core message: Integration tests give you the best return on investment because they test the way your software actually works — multiple units collaborating — without the fragility of E2E tests.

Continue reading Test Pyramid vs Test Trophy: What Actually Works in Production

Unit vs Integration vs E2E Testing in JUnit 6 (Practical Guide)

Not all tests are equal. A unit test that runs in 5ms and a full end-to-end test that takes 3 minutes serve completely different purposes — and confusing the two leads to test suites that are either too slow to be useful or too shallow to catch real bugs. This guide gives you the precise definition, scope, tools, and trade-offs for each test type in JUnit 6, with production-grade examples that show exactly what belongs in each layer.

The Three Testing Layers

Unit TestIntegration TestE2E Test
What it testsOne class in isolationMultiple components togetherEntire system as a user would
DependenciesAll mockedSome real, some mockedAll real
Speed<10ms per test100ms – 10s per testSeconds to minutes
ReliabilityExtremely reliableUsually reliableCan be flaky (network, timing)
Scope of feedbackPinpoints exact linePoints to integration boundarySays “something is broken”
JUnit 6 setup@ExtendWith (MockitoExtension.class)@SpringBootTest, @DataJpaTest@SpringBootTest(RANDOM_PORT) + Docker

Layer 1: Unit Tests

A unit test verifies the behaviour of a single class — no database, no HTTP, no file system. All external dependencies are replaced with mocks or stubs. This makes unit tests the fastest, most reliable, and most informative tests you can write.

// UNIT TEST: Tests OrderService in complete isolation.
// No Spring context, no database, no HTTP — dependencies mocked.
@ExtendWith(MockitoExtension.class)
@DisplayName("OrderService — unit tests")
class OrderServiceUnitTest {

    @Mock private OrderRepository orderRepository; // mocked
    @Mock private PaymentGateway  paymentGateway;  // mocked
    @Mock private EmailService    emailService;    // mocked
    @InjectMocks private OrderService orderService; // real class under test

    @Test
    @Tag("unit")
    @DisplayName("Placing a valid order saves it and sends a confirmation email")
    void placingValidOrderSavesItAndSendsEmail() {
        // Arrange: define mock behaviour
        Order savedOrder = new Order(1L, "[email protected]", 49.99, OrderStatus.CONFIRMED);
        when(paymentGateway.authorise(anyDouble())).thenReturn("AUTH-001");
        when(orderRepository.save(any())).thenReturn(savedOrder);

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

        // Assert behaviour AND interactions
        assertEquals(OrderStatus.CONFIRMED, result.getStatus());
        verify(emailService, times(1)).sendConfirmation("[email protected]");
    }

    @Test
    @Tag("unit")
    @DisplayName("Payment gateway failure cancels order immediately")
    void paymentGatewayFailureCancelsOrder() {
        when(paymentGateway.authorise(anyDouble()))
            .thenThrow(new PaymentDeclinedException("Card declined"));

        assertThrows(PaymentDeclinedException.class,
            () -> orderService.place("[email protected]", 49.99));

        // Repository must NOT be called if payment fails
        verifyNoInteractions(orderRepository, emailService);
    }
}
Continue reading Unit vs Integration vs E2E Testing in JUnit 6 (Practical Guide)

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