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);
    }
}

Layer 2: Integration Tests

An integration test verifies that multiple components work correctly together. It typically involves a real database, real HTTP calls within the service, or real Spring context wiring — but stubs external services the application calls.

// INTEGRATION TEST: Tests the full web layer against a real controller, real service,
// and a real H2/Testcontainers database. External payment gateway is mocked.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@Tag("integration")
@DisplayName("Order API — integration tests")
class OrderApiIntegrationTest {

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

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

    @Autowired
    private TestRestTemplate restTemplate;

    // Mock the EXTERNAL payment gateway only — everything else is real
    @MockBean
    private PaymentGateway paymentGateway;

    @BeforeEach
    void stubPaymentGateway() {
        when(paymentGateway.authorise(anyDouble())).thenReturn("AUTH-999");
    }

    @Test
    @DisplayName("POST /orders creates order, saves to DB, returns 201")
    void postOrderCreatesOrderAndReturns201() {
        CreateOrderRequest request =
            new CreateOrderRequest("[email protected]", 99.99);

        ResponseEntity<OrderDto> response =
            restTemplate.postForEntity("/api/orders", request, OrderDto.class);

        // Verify HTTP layer
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody().getId());

        // Verify data was actually persisted (uses real DB)
        ResponseEntity<OrderDto> getResponse = restTemplate.getForEntity(
            "/api/orders/" + response.getBody().getId(), OrderDto.class);
        assertEquals(HttpStatus.OK, getResponse.getStatusCode());
        assertEquals(OrderStatus.CONFIRMED, getResponse.getBody().getStatus());
    }
}

Layer 3: End-to-End Tests

// E2E TEST: Verifies the complete checkout journey across all real services.
// Uses Docker Compose to start the entire system.
// No mocks — everything is real.
@Testcontainers
@Tag("e2e")
@DisplayName("Checkout — end-to-end journey tests")
class CheckoutE2ETest {

    @Container
    static DockerComposeContainer<?> system =
        new DockerComposeContainer<>(new File("docker-compose.test.yml"))
            .withExposedService("api-gateway", 8080);

    private String apiBaseUrl;

    @BeforeEach
    void setUp() {
        apiBaseUrl = "http://" +
            system.getServiceHost("api-gateway", 8080) + ":" +
            system.getServicePort("api-gateway", 8080);
    }

    @Test
    @DisplayName("Full checkout: browse product, add to cart, place order, confirm payment")
    void fullCheckoutJourney() {
        RestTemplate client = new RestTemplate();

        // Step 1: Browse product catalogue
        ProductDto product = client.getForObject(
            apiBaseUrl + "/api/products/LAPTOP-01", ProductDto.class);
        assertTrue(product.isInStock());

        // Step 2: Place order
        CreateOrderRequest order = new CreateOrderRequest("[email protected]", "LAPTOP-01", 1);
        ResponseEntity<OrderDto> orderResponse = client.postForEntity(
            apiBaseUrl + "/api/orders", order, OrderDto.class);
        assertEquals(HttpStatus.CREATED, orderResponse.getStatusCode());
        Long orderId = orderResponse.getBody().getId();

        // Step 3: Poll for async payment confirmation (event-driven)
        await().atMost(Duration.ofSeconds(15))
            .pollInterval(Duration.ofMillis(500))
            .untilAsserted(() -> {
                OrderDto updated = client.getForObject(
                    apiBaseUrl + "/api/orders/" + orderId, OrderDto.class);
                assertEquals(OrderStatus.PAYMENT_CONFIRMED, updated.getStatus());
            });
    }
}

The Right Balance: How Many Tests at Each Layer

LayerRecommended proportionTypical count (medium project)
Unit~70%300–500 tests
Integration~20%50–100 tests
E2E~10%10–20 tests

Frequently Asked Questions (FAQs)

Q1: Is a @DataJpaTest a unit test or an integration test?

It is an integration test. Even though it is focused and fast, @DataJpaTest loads a partial Spring context and interacts with a database (H2 or Testcontainers). The term “unit” in testing means the class under test has no real external dependencies — everything is mocked. Since @DataJpaTest tests interact with a real database, they are integration tests, even lightweight ones.

Q2: Should every service method have a unit test AND an integration test?

Not necessarily. Write unit tests for all service logic — every branch, edge case, and error path. Write integration tests to verify the service works correctly when wired together with its real dependencies — not to re-test every edge case (that’s what unit tests are for). A typical integration test covers the happy path and one or two critical error paths per endpoint or feature. Edge cases belong in unit tests.

Q3: When should I add an E2E test vs an integration test?

Add an E2E test for critical user journeys that span multiple services — checkout, user registration, order cancellation. Add an integration test for a single service’s behaviour when interacting with real dependencies. If the scenario can be tested within one service (even with mocked external services), prefer an integration test. E2E tests are expensive to maintain and should cover only the flows whose failure would be immediately noticed by users.

Q4: What is a “component test” and how does it differ from integration tests?

A component test (sometimes called a “service integration test”) tests a microservice as a black box — real HTTP in, real HTTP out, but with all external service calls stubbed via WireMock. It sits between integration tests (which test internal layer interactions) and E2E tests (which test multiple real services). Component tests give high confidence that a service behaves correctly at its API boundary without the fragility of full E2E tests.

Q5: How do I tag tests to separate unit, integration, and E2E in JUnit 6?

Apply @Tag("unit"), @Tag("integration"), and @Tag("e2e") at the class level. Then configure Maven Surefire or Gradle to filter by tag per pipeline stage. See Tags and Test Suites in JUnit 6 for the complete filtering setup.

See Also

Conclusion

Unit tests, integration tests, and E2E tests are not competing approaches — they are complementary layers of a healthy test suite. Unit tests give you fast, precise feedback on business logic. Integration tests verify that components wire together correctly. E2E tests confirm that critical user journeys work end-to-end. Apply them in the right proportions — many units, some integration, few E2E — and you get maximum confidence at minimum maintenance cost.

Next: Test Pyramid vs Test Trophy: What Actually Works in Production — explore two competing models for structuring your test suite and understand which fits your architecture.

Leave a Reply

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