Skip to main content

Test Pyramid vs Test Trophy: What Actually Works in Production

An honest comparison of the Test Pyramid and Test Trophy testing models, with JUnit 6 code examples for each approach, analysis of when each model works best, and a practical hybrid strategy for real-world Java projects.

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.

Applying Both Models with JUnit 6

Pyramid-Style: Heavy Unit Testing

// PYRAMID approach: the bulk of tests are pure unit tests.
// Logic is tested in complete isolation with Mockito.

@ExtendWith(MockitoExtension.class)
@Tag("unit")
class PricingEngineTest {

    @InjectMocks private PricingEngine pricingEngine;
    @Mock        private TaxCalculator taxCalculator;
    @Mock        private DiscountService discountService;

    @ParameterizedTest(name = "Price {0} with tax rate {1}% = {2}")
    @CsvSource({
        "100.00, 20, 120.00",
        "50.00,  10,  55.00",
        "0.00,   20,   0.00"
    })
    void priceWithTaxCalculatesCorrectly(double basePrice,
                                          double taxRatePercent,
                                          double expectedFinalPrice) {
        // Stub tax calculation
        when(taxCalculator.calculate(basePrice, taxRatePercent))
            .thenReturn(basePrice * taxRatePercent / 100);
        when(discountService.getDiscount(any())).thenReturn(0.0); // no discount

        double finalPrice = pricingEngine.calculateFinalPrice(basePrice, taxRatePercent);

        assertEquals(expectedFinalPrice, finalPrice, 0.001);
    }

    // Many more focused tests: edge cases, boundaries, error paths
    @Test
    void negativeBasePriceThrowsIllegalArgumentException() {
        assertThrows(IllegalArgumentException.class,
            () -> pricingEngine.calculateFinalPrice(-1.0, 20.0));
    }
}

Trophy-Style: Emphasis on Integration Tests

// TROPHY approach: integration tests form the bulk of the suite.
// Tests verify real-world behaviour through the HTTP API.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@Tag("integration")
class CheckoutApiTrophyStyleTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configurePostgres(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url",      postgres::getJdbcUrl);
        r.add("spring.datasource.username", postgres::getUsername);
        r.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired private TestRestTemplate restTemplate;

    // Trophy-style integration test covers happy path AND validation error
    // in one focused scenario without mocking any internal components
    @Test
    @DisplayName("Valid checkout creates order, deducts stock, sends email")
    void validCheckoutCreatesOrderDeductsStockAndSendsEmail() {
        // Full real flow: controller → service → repository → real DB
        // External email and payment are WireMocked at infrastructure level
        CreateOrderRequest request = new CreateOrderRequest("[email protected]", "PROD-01", 1);
        ResponseEntity<OrderDto> response =
            restTemplate.postForEntity("/api/orders", request, OrderDto.class);

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

When the Pyramid Works Best

  • Domain-heavy applications with complex business logic (insurance pricing, financial calculations, tax engines) where the logic itself is the most valuable thing to test
  • Library or SDK development where the public API surface is the contract and pure functionality matters most
  • Algorithmic code (sorting, parsing, graph algorithms) that benefits from hundreds of parameterized edge case tests
  • Legacy codebases being refactored — unit tests lock in current behaviour before changes

When the Trophy Works Best

  • CRUD-heavy REST APIs where most of the value is in verifying that HTTP → service → database roundtrips work correctly
  • React/Node.js frontends (Kent Dodds’ original context) where rendering-level integration tests give more value than isolated component unit tests
  • Microservices with thin service layers and most complexity at the integration boundary
  • Projects where mocking is painful — if you spend more time maintaining mock setup than writing assertions, shift to integration tests

The Practical Hybrid: What Actually Works

Most production Java projects do best with a hybrid that borrows from both models:

Test typeWhen to write itJUnit 6 setup
UnitComplex business logic, algorithms, validation, edge cases@ExtendWith(MockitoExtension.class)
Slice (controller, JPA)HTTP mapping, JSON serialisation, JPA queries@WebMvcTest, @DataJpaTest
IntegrationFull service flow with real DB (Testcontainers)@SpringBootTest + Testcontainers
E2ECritical user journeys only (checkout, registration)DockerComposeContainer

Frequently Asked Questions (FAQs)

Q1: Is the Test Pyramid outdated?

Not outdated, but not universal either. The Pyramid was designed for applications with thick domain layers where unit tests cover the most complex code. For API-centric applications, the Trophy or a hybrid better reflects where value lives. Both models are tools, not commandments. Use whichever shape produces the most useful feedback for your specific architecture.

Q2: How do I know if I have too many mocks and should shift to integration tests?

Watch for these signals: mock setup is longer than the actual test assertions, changing production code requires updating many mocks without any logic change, tests pass but bugs still reach production, or your tests are coupled to implementation details rather than behaviour. These are all signs that your unit tests are testing the wrong thing and integration tests would give more genuine confidence.

Q3: Can I mix Pyramid and Trophy-style tests in the same project?

Yes, and this is normal in real projects. Use Pyramid-style (heavy unit tests) for your domain and business logic packages. Use Trophy-style (heavy integration tests) for your API layer. The JUnit 6 tagging system lets you run each group independently so the different speeds do not conflict in your CI pipeline.

Q4: What does “static analysis” mean in the Test Trophy base layer?

In the Trophy model, static analysis (the base layer) includes tools like SpotBugs, PMD, Checkstyle, and SonarQube that catch bugs, code smells, and style violations without running the code. In Java, @SuppressWarnings and compiler warnings also belong here. These tools catch a class of errors (null dereferences, resource leaks, API misuse) that are difficult to catch with tests.

Q5: How many tests is too many?

There is no upper limit on tests — but there is a cost to each one. A test suite is too large when: it takes more than 5 minutes to run locally, making a simple change requires updating dozens of tests, or the suite has significant duplication across layers. If your test suite feels like a burden rather than an asset, audit which tests are providing unique value and which are testing the same behaviour in multiple layers unnecessarily.

See Also

Conclusion

Neither the Test Pyramid nor the Test Trophy is universally correct. The Pyramid works well for domain-heavy code with complex logic. The Trophy works well for API-centric applications where integration-level tests provide the most realistic coverage. In practice, most successful Java projects use a hybrid: many unit tests for complex domain logic, many integration tests for API flows, and a small set of E2E tests for critical user journeys. The specific shape matters less than the discipline of writing tests at the right layer for the right reason.

Next: Writing Maintainable Tests in JUnit 6 — clean code principles applied to test code so your test suite stays readable and valuable as the project grows.

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.