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.
10. Integration Testing with @WebMvcTest and MockMvc
@WebMvcTest loads only the Spring MVC layer — controllers, filters, advice, and converters — without starting a full application context or database. This makes it 3–5× faster than @SpringBootTest for testing HTTP contract: status codes, serialization, error responses, and security filters:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void createUser_returns201WithLocation() throws Exception {
var user = new User(42L, "alice", "alice@example.com");
given(userService.create(any())).willReturn(user);
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","email":"alice@example.com"}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/users/42")))
.andExpect(jsonPath("$.username").value("alice"));
}
@Test
void createUser_returns400OnInvalidEmail() throws Exception {
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","email":"not-an-email"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
}
For testing secured endpoints, use @WithMockUser from Spring Security Test to inject an authenticated principal without going through the full OAuth2 flow. For JWT-based APIs, use SecurityMockMvcRequestPostProcessors.jwt() to construct a token with the exact claims your authorization logic checks:
@Test
void adminEndpoint_allowsAdminRole() throws Exception {
mockMvc.perform(get("/admin/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
@Test
void adminEndpoint_rejects403ForUserRole() throws Exception {
mockMvc.perform(get("/admin/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
11. Testcontainers: Real Databases and Message Brokers in Tests
H2 in-memory databases hide real-world bugs — dialect differences, constraint violations that H2 silently ignores, and missing PostgreSQL-specific functions. Testcontainers runs a real Docker container for your database, Kafka, or Redis during tests, giving you production-identical behavior without a persistent test environment:
// pom.xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(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 UserRepository userRepository;
@Test
void save_andFind_worksOnRealPostgres() {
var saved = userRepository.save(new User(null, "alice", "alice@example.com"));
assertThat(saved.id()).isNotNull();
Optional<User> found = userRepository.findById(saved.id());
assertThat(found).isPresent().hasValueSatisfying(u ->
assertThat(u.email()).isEqualTo("alice@example.com")
);
}
}
For Kafka integration tests, use KafkaContainer and assert that your consumer processes messages with the expected semantics — including key ordering and deserialization:
@Container
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Test
void orderPlaced_eventIsPublishedAndConsumed() throws Exception {
orderService.placeOrder(new Order("item-123", 2));
// Poll for the event — real Kafka, real serialization
ConsumerRecord<String, OrderPlacedEvent> record =
KafkaTestUtils.getSingleRecord(consumer, "order-placed");
assertThat(record.value().itemId()).isEqualTo("item-123");
assertThat(record.value().quantity()).isEqualTo(2);
}
Testcontainers containers start once per test class (the static modifier) and are reused across tests in that class, minimizing startup overhead. For multi-module projects, use the Testcontainers reuse mechanism (.withReuse(true)) to share a single container across all test runs in a developer's session.
12. WireMock: Stubbing External HTTP APIs in Tests
Spring Boot services routinely call external APIs — payment processors, notification services, third-party data providers. Testing these integrations with real APIs makes tests slow, flaky, and dependent on network access. WireMock stubs HTTP endpoints locally so your Spring RestClient or WebClient calls receive realistic responses without leaving the JVM:
// pom.xml
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-spring-boot-starter</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@EnableWireMock
class PaymentServiceTest {
@InjectWireMock
private WireMockServer wireMock;
@Autowired
private PaymentService paymentService;
@Test
void processPayment_returns200OnSuccess() {
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"id":"ch_123","status":"succeeded","amount":5000}
""")));
PaymentResult result = paymentService.charge("tok_visa", 5000);
assertThat(result.status()).isEqualTo("succeeded");
}
@Test
void processPayment_throwsOn402() {
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(402)
.withBody("""
{"error":{"code":"card_declined","message":"Your card was declined."}}
""")));
assertThatThrownBy(() -> paymentService.charge("tok_declined", 5000))
.isInstanceOf(PaymentDeclinedException.class)
.hasMessageContaining("card_declined");
}
}
WireMock also supports response templating (dynamic responses using Handlebars), stateful scenarios (different responses for sequential calls), and fault injection (connection reset, malformed responses) to test your resilience logic:
// Fault injection — test circuit breaker behavior
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withFault(Fault.CONNECTION_RESET_BY_PEER)));
// Simulate intermittent timeout
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.willReturn(aResponse()
.withFixedDelay(5000) // 5 second delay — triggers read timeout
.withStatus(200)
.withBody("...")));
// Stateful: first call fails, retry succeeds
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs(STARTED)
.willReturn(serverError())
.willSetStateTo("second-attempt"));
wireMock.stubFor(post(urlPathEqualTo("/v1/charges"))
.inScenario("retry-scenario")
.whenScenarioStateIs("second-attempt")
.willReturn(ok().withBody("""{"status":"succeeded"}""")));
The complete testing strategy for a production Spring Boot microservice combines all three layers: Mockito for fast service-layer logic tests, WireMock for HTTP client integration tests, and Testcontainers for repository and message broker integration tests. Each layer runs in isolation, and together they give you high confidence without requiring a deployed environment.
13. Spring Boot Test Slices: Targeted Context Loading
Spring Boot provides test slice annotations that load only the portion of the application context relevant to what you're testing. This dramatically reduces test startup time — a full @SpringBootTest context that takes 15 seconds can be reduced to under 1 second with the right slice. Here are the most valuable slices and when to use them:
| Annotation | Loads | Use For |
|---|---|---|
@WebMvcTest |
Controllers, filters, advice | HTTP contract, serialization, security |
@DataJpaTest |
JPA repositories, entity scanning | Repository queries, custom SQL, constraints |
@JsonTest |
Jackson ObjectMapper only | DTO serialization/deserialization contracts |
@RestClientTest |
RestTemplate/RestClient beans | HTTP client configuration and error handling |
@SpringBootTest |
Full application context | End-to-end smoke tests, cross-cutting concerns |
// @DataJpaTest — real JPA, no web layer, auto-configures test DB
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Use Testcontainers, not H2
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void findActiveByEmail_returnsOnlyActiveUsers() {
userRepository.saveAll(List.of(
User.builder().email("active@x.com").status(ACTIVE).build(),
User.builder().email("inactive@x.com").status(INACTIVE).build()
));
List<User> result = userRepository.findActiveByEmail("%@x.com");
assertThat(result).hasSize(1)
.extracting(User::email)
.containsOnly("active@x.com");
}
}
The golden rule: choose the narrowest slice that covers what you're testing. A @DataJpaTest for a repository query is 10× faster than @SpringBootTest and gives you the same confidence for that specific concern. Reserve full @SpringBootTest for cross-cutting tests that verify interactions between layers — security filters, serialization, and bean wiring — where isolated slices can't catch the problem.
14. Beyond Code Coverage: Measuring Test Quality
80% line coverage is a common team target, but it's a misleading metric. A test suite can achieve 80% coverage with assertions that never actually fail — tests that call code but don't verify outputs. The real measure of test quality is mutation coverage: how many artificial bugs (mutations) does your test suite detect and kill? PIT (PITest) mutation testing framework for Java answers this question precisely.
<!-- pom.xml — PITest mutation testing plugin -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.3</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<targetClasses>com.example.service.*</targetClasses>
<targetTests>com.example.service.*Test</targetTests>
<mutationThreshold>70</mutationThreshold> <!-- fail build below 70% -->
</configuration>
</plugin>
PIT introduces mutations such as changing == to !=, removing method calls, replacing return values with null, and negating conditionals. A mutation is "killed" if at least one test fails when the mutation is active. A surviving mutation means your tests don't distinguish between the correct behavior and a subtly wrong variant — a real gap in test quality.
Target 70% mutation coverage on your service layer as the starting minimum. For business-critical financial or security logic, push to 85–90%. The report PIT generates is far more actionable than line coverage: it shows you exactly which mutations survived and in which code paths, guiding you to add assertions that catch the gaps. Integrate PIT into your CI pipeline as a weekly check rather than per-commit to keep build times reasonable, since full mutation runs can take 10–30 minutes for large codebases.
Tags
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices