JUnit 5 Mockito unit testing Spring Boot microservices
Md Sanwar Hossain
Md Sanwar Hossain
Senior Software Engineer · Spring Boot Testing Series
Testing April 4, 2026 22 min read Spring Boot Testing Series

JUnit 5 & Mockito Best Practices: Effective Unit Testing for Spring Boot Microservices

Most developers know how to write a JUnit test. Far fewer know how to write a good one. Brittle tests that verify implementation details rather than behavior, Mockito stubs that return happy-path values regardless of input, and test classes that need a 10-second Spring context startup for every run — these are the productivity killers that slow down senior Java teams. This guide covers the JUnit 5 and Mockito patterns that separate effective unit tests from expensive noise in your CI pipeline.

Table of Contents

  1. The Test Pyramid for Spring Boot Microservices
  2. JUnit 5 Essentials: Lifecycle, Extensions, and Nested Tests
  3. Mockito Setup: @ExtendWith vs @SpringBootTest
  4. Stubbing Strategies: when/thenReturn vs doReturn/when
  5. ArgumentCaptor: Verifying What You Pass to Dependencies
  6. Parameterized Tests: @MethodSource, @CsvSource, and @EnumSource
  7. Spy vs Mock: When to Partially Mock Real Objects
  8. Common Anti-Patterns and How to Fix Them
  9. Key Takeaways

1. The Test Pyramid for Spring Boot Microservices

Test pyramid for Spring Boot microservices JUnit 5 Mockito | mdsanwarhossain.me
Spring Boot Test Pyramid — mdsanwarhossain.me

The classic test pyramid allocates most tests at the unit level (fast, isolated, no Spring context), fewer at the integration level (database, Kafka, real containers), and a small suite of E2E tests. For a Spring Boot microservice, this translates to: service layer unit tests with Mockito (70%), repository tests with @DataJpaTest + Testcontainers (20%), and controller tests with @WebMvcTest + MockMvc (10%). E2E tests run on a deployed environment only.

The most common violation is over-relying on @SpringBootTest for service layer tests. A full Spring context takes 5–30 seconds to start, multiplied by dozens of test classes, adds minutes to every CI run. The same coverage is achievable with plain JUnit 5 + Mockito, which starts in milliseconds.

Speed Benchmark: A Spring Boot service layer test with @SpringBootTest typically takes 8–15 seconds per class. The equivalent Mockito-only test takes under 200 milliseconds.

2. JUnit 5 Essentials: Lifecycle, Extensions, and Nested Tests

JUnit 5's extension model replaces the old JUnit 4 runners. The @ExtendWith annotation wires in extensions such as Mockito's MockitoExtension, which initializes @Mock and @InjectMocks fields before each test method. Use @BeforeEach for per-test setup, @BeforeAll for expensive shared setup (static), and @Nested to group related tests in a readable hierarchy.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private InventoryClient inventoryClient;

    @Mock
    private EventPublisher eventPublisher;

    @InjectMocks
    private OrderService orderService;

    @Nested
    @DisplayName("Place Order")
    class PlaceOrderTests {

        @BeforeEach
        void setUp() {
            when(inventoryClient.isAvailable("sku-A", 2)).thenReturn(true);
        }

        @Test
        @DisplayName("should create order and publish event when inventory available")
        void shouldCreateOrderAndPublishEvent() {
            OrderRequest request = new OrderRequest("user-1", "sku-A", 2);
            Order savedOrder = new Order(UUID.randomUUID(), "user-1", "PENDING");
            when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

            Order result = orderService.placeOrder(request);

            assertThat(result.getStatus()).isEqualTo("PENDING");
            verify(eventPublisher).publish(any(OrderCreatedEvent.class));
        }

        @Test
        @DisplayName("should throw InsufficientInventoryException when out of stock")
        void shouldThrowWhenInventoryUnavailable() {
            when(inventoryClient.isAvailable("sku-A", 2)).thenReturn(false);

            assertThatThrownBy(() -> orderService.placeOrder(new OrderRequest("user-1", "sku-A", 2)))
                .isInstanceOf(InsufficientInventoryException.class)
                .hasMessageContaining("sku-A");

            verify(orderRepository, never()).save(any());
        }
    }
}

3. Mockito Setup: @ExtendWith vs @SpringBootTest

Use @ExtendWith(MockitoExtension.class) for pure service layer tests — no Spring context, no autowiring, just plain Java. Use @MockBean inside @SpringBootTest or @WebMvcTest only when you need the full Spring container, such as testing security filters or the Jackson serialization chain in a controller. Mixing the two strategies in the same class leads to confusing behavior.

// ✅ Preferred for service/domain logic — no Spring context
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
    @Mock PaymentGatewayClient gatewayClient;
    @InjectMocks PaymentService paymentService;
    // Test runs in <100ms
}

// ✅ Use @WebMvcTest for controller tests — Spring MVC context only
@WebMvcTest(PaymentController.class)
class PaymentControllerTest {
    @Autowired MockMvc mockMvc;
    @MockBean PaymentService paymentService; // spring-managed mock
}

// ❌ Avoid for service tests — 10+ second startup for no benefit
@SpringBootTest
class PaymentServiceHeavyTest {
    @MockBean PaymentGatewayClient gatewayClient;
    @Autowired PaymentService paymentService;
    // Same coverage, 50x slower
}

4. Stubbing Strategies: when/thenReturn vs doReturn/when

Mockito stubbing strategies Spring Boot unit testing | mdsanwarhossain.me
Mockito Stubbing Strategies — mdsanwarhossain.me

The when(mock.method()).thenReturn(value) syntax is the standard for non-void methods on regular mocks. It calls the stubbed method once during setup — which is fine for mocks but causes NullPointerException when used on Spy objects because the real method is invoked. For spies or void methods, use the doReturn(value).when(spy).method() form instead.

// Standard stubbing for mocks
when(userRepository.findById(42L)).thenReturn(Optional.of(testUser));
when(userRepository.findById(99L)).thenReturn(Optional.empty());

// Chained responses — first call returns user, second throws
when(userRepository.findById(1L))
    .thenReturn(Optional.of(testUser))
    .thenThrow(new DataAccessException("DB unavailable") {});

// Throw checked exception on void method
doThrow(new MailException("SMTP down") {})
    .when(emailService).sendWelcomeEmail(anyString());

// Stub a spy without calling the real method
OrderService spyService = spy(new OrderService(repository));
doReturn(BigDecimal.ZERO).when(spyService).calculateDiscount(any());

// Answer — dynamic response based on argument
when(inventoryClient.reserveStock(anyString(), anyInt()))
    .thenAnswer(invocation -> {
        int quantity = invocation.getArgument(1);
        return quantity <= 100; // return true only for reasonable quantities
    });

5. ArgumentCaptor: Verifying What You Pass to Dependencies

verify(mock).method(expectedArg) checks that a dependency was called, but it can only match by equality or matchers. When the argument is a complex object constructed internally by the class under test, ArgumentCaptor captures the actual object passed so you can assert against its fields. This is particularly valuable for verifying domain events, audit records, and email payloads built from multiple inputs.

@Test
void shouldPublishOrderCreatedEventWithCorrectDetails() {
    // Given
    OrderRequest request = new OrderRequest("user-123", "sku-A", 3, "EUR");
    when(orderRepository.save(any())).thenAnswer(inv -> {
        Order o = inv.getArgument(0);
        o.setId(UUID.fromString("a1b2c3d4-..."));
        return o;
    });

    // When
    orderService.placeOrder(request);

    // Then — capture the event published to the event bus
    ArgumentCaptor<OrderCreatedEvent> eventCaptor =
        ArgumentCaptor.forClass(OrderCreatedEvent.class);
    verify(eventPublisher).publish(eventCaptor.capture());

    OrderCreatedEvent capturedEvent = eventCaptor.getValue();
    assertThat(capturedEvent.getUserId()).isEqualTo("user-123");
    assertThat(capturedEvent.getSkuCode()).isEqualTo("sku-A");
    assertThat(capturedEvent.getQuantity()).isEqualTo(3);
    assertThat(capturedEvent.getCurrency()).isEqualTo("EUR");
    assertThat(capturedEvent.getTimestamp()).isNotNull();
}

// Capturing multiple invocations
@Test
void shouldSendEmailsToAllOrderParticipants() {
    orderService.placeOrder(multiPartyRequest);

    ArgumentCaptor<EmailRequest> emailCaptor =
        ArgumentCaptor.forClass(EmailRequest.class);
    verify(emailService, times(2)).sendEmail(emailCaptor.capture());

    List<EmailRequest> emails = emailCaptor.getAllValues();
    assertThat(emails).extracting(EmailRequest::getRecipient)
        .containsExactlyInAnyOrder("buyer@example.com", "seller@example.com");
}

6. Parameterized Tests: @MethodSource, @CsvSource, and @EnumSource

Parameterized tests replace the copy-paste test method pattern. Instead of five identical test methods varying only the input, a single @ParameterizedTest method runs against a stream of arguments. JUnit 5 provides three commonly used sources: @CsvSource for tabular inline data, @MethodSource for complex objects from a factory method, and @EnumSource for exhaustive enum coverage.

// @CsvSource — tabular test cases inline
@ParameterizedTest(name = "order {0} units at price {1} should total {2}")
@CsvSource({
    "1,   100.00, 100.00",
    "3,    50.00, 150.00",
    "10,   29.99, 299.90",
    "0,   100.00,   0.00"
})
void shouldCalculateOrderTotal(int qty, BigDecimal price, BigDecimal expected) {
    BigDecimal total = pricingService.calculateTotal(qty, price);
    assertThat(total).isEqualByComparingTo(expected);
}

// @MethodSource — complex objects from factory
@ParameterizedTest
@MethodSource("invalidOrderRequests")
void shouldRejectInvalidOrders(OrderRequest request, String expectedMessage) {
    assertThatThrownBy(() -> orderService.placeOrder(request))
        .isInstanceOf(ValidationException.class)
        .hasMessageContaining(expectedMessage);
}

static Stream<Arguments> invalidOrderRequests() {
    return Stream.of(
        arguments(new OrderRequest(null, "sku-A", 1), "userId is required"),
        arguments(new OrderRequest("u1", null, 1), "skuCode is required"),
        arguments(new OrderRequest("u1", "sku-A", 0), "quantity must be positive"),
        arguments(new OrderRequest("u1", "sku-A", -5), "quantity must be positive")
    );
}

// @EnumSource — test all payment methods
@ParameterizedTest
@EnumSource(PaymentMethod.class)
void shouldAcceptAllPaymentMethods(PaymentMethod method) {
    OrderRequest req = new OrderRequest("u1", "sku-A", 1, method);
    when(inventoryClient.isAvailable(any(), anyInt())).thenReturn(true);
    when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

    assertThatCode(() -> orderService.placeOrder(req)).doesNotThrowAnyException();
}

7. Spy vs Mock: When to Partially Mock Real Objects

A Mock is a hollow shell — all methods return default values (null, 0, empty collections) unless stubbed. A Spy wraps a real object and delegates all unstubbed calls to the actual implementation. Use spies when you want to test a real implementation but isolate one collaborator method that would call an external system.

@ExtendWith(MockitoExtension.class)
class DiscountServiceTest {

    // Spy — real implementation, but we can stub specific methods
    @Spy
    private DiscountService discountService = new DiscountService();

    @Test
    void shouldApplyVIPDiscountOnTopOfStandardDiscount() {
        // Stub the external loyalty tier lookup only
        doReturn(LoyaltyTier.VIP).when(discountService).getLoyaltyTier("user-gold");

        // Real implementation calculates standard discount (10%)
        // then applies VIP modifier (additional 5%)
        BigDecimal discounted = discountService.calculate("user-gold", BigDecimal.valueOf(200));

        assertThat(discounted).isEqualByComparingTo("170.00"); // 200 * 0.85
    }
}

// Using @Spy annotation — Mockito calls new DiscountService() automatically
@ExtendWith(MockitoExtension.class)
class AnnotationSpyTest {
    @Spy
    DiscountService discountService; // default constructor required

    @Test
    void shouldUseRealImplementationForUnstubbedMethods() {
        // Only stub the external call
        doReturn(LoyaltyTier.STANDARD).when(discountService).getLoyaltyTier(anyString());

        BigDecimal result = discountService.calculate("any-user", BigDecimal.valueOf(100));

        assertThat(result).isEqualByComparingTo("90.00"); // real 10% standard discount
    }
}

8. Common Anti-Patterns and How to Fix Them

These are the patterns that make test suites slow, brittle, and impossible to maintain:

Anti-Pattern Why It Hurts Fix
@SpringBootTest for service tests 8–20 second startup per class @ExtendWith(MockitoExtension.class)
Stubbing every method of every mock Tests implementation, not behavior Stub only what the test path exercises
verify(mock, times(1)).method() everywhere Ties tests to call count, breaks on refactor Verify only interactions that matter for correctness
Mocking value objects (DTOs, records) Creates hollow objects, hides null bugs Instantiate real objects using constructors or builders
Shared mutable state between tests Tests become order-dependent and flaky Re-initialize in @BeforeEach, use @ExtendWith

9. Key Takeaways

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: April 4, 2026