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
- The Test Pyramid for Spring Boot Microservices
- JUnit 5 Essentials: Lifecycle, Extensions, and Nested Tests
- Mockito Setup: @ExtendWith vs @SpringBootTest
- Stubbing Strategies: when/thenReturn vs doReturn/when
- ArgumentCaptor: Verifying What You Pass to Dependencies
- Parameterized Tests: @MethodSource, @CsvSource, and @EnumSource
- Spy vs Mock: When to Partially Mock Real Objects
- Common Anti-Patterns and How to Fix Them
- Key Takeaways
1. The Test Pyramid for Spring Boot Microservices
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.
@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
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
- Use
@ExtendWith(MockitoExtension.class)for service layer tests — no Spring context needed. - Reserve
@SpringBootTestfor cross-cutting concerns: security filters, serialization, context wiring. - Prefer
doReturn/whenoverwhen/thenReturnfor spy objects to avoid invoking real methods during stubbing. - Use
ArgumentCaptorto verify complex objects passed to dependencies — especially domain events and emails. - Replace duplicated test methods with
@ParameterizedTest+@MethodSourcefor complex inputs or@CsvSourcefor tabular data. - Use
@Nestedclasses to group related tests and improve readability in large test classes. - Verify only meaningful interactions — avoid over-verification that ties tests to implementation details.
- Never mock value objects or data transfer objects — construct them with real values to catch null and type bugs.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices