Unit Testing Services with JUnit 5 & Mockito
A unit test verifies a single class in complete isolation. When you test a Spring Boot service, its collaborators — repositories, email senders, external clients — must not touch a real database or network. Mockito lets you replace those collaborators with controlled fakes called mocks, so your test runs in milliseconds and fails for one reason only: the logic inside the class under test.
Why isolation matters: A test that fails because the database is down is not telling you your code is broken — it is telling you your infrastructure is broken. Unit tests must be deterministic, fast, and independent of infrastructure.
Project Setup
Spring Boot's test starter pulls in JUnit 5 and Mockito automatically:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
This brings in junit-jupiter, mockito-core, mockito-junit-jupiter, and AssertJ. You need nothing extra for pure unit tests.
The Class Under Test
Consider an OrderService that depends on an OrderRepository and an InventoryClient. Both are injected via constructor — the preferred style in Spring Boot 3 because it makes the dependency graph explicit and supports unit testing without a Spring context:
// OrderService.java
package com.example.shop.service;
import com.example.shop.client.InventoryClient;
import com.example.shop.model.Order;
import com.example.shop.repository.OrderRepository;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryClient inventoryClient;
public OrderService(OrderRepository orderRepository,
InventoryClient inventoryClient) {
this.orderRepository = orderRepository;
this.inventoryClient = inventoryClient;
}
public Order placeOrder(Long productId, int quantity) {
if (!inventoryClient.isAvailable(productId, quantity)) {
throw new IllegalStateException("Insufficient stock for product " + productId);
}
Order order = new Order(productId, quantity);
return orderRepository.save(order);
}
public Order findById(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Order not found: " + id));
}
}
Writing the Unit Test
The @ExtendWith(MockitoExtension.class) annotation wires Mockito into JUnit 5's extension model. It initialises mocks annotated with @Mock before each test and validates their usage afterwards. The service is created manually, receiving the mocks via constructor — no Spring context is started:
// OrderServiceTest.java
package com.example.shop.service;
import com.example.shop.client.InventoryClient;
import com.example.shop.model.Order;
import com.example.shop.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private InventoryClient inventoryClient;
@InjectMocks
private OrderService orderService;
// --- tests follow ---
}
@Mock creates a Mockito proxy; @InjectMocks instantiates OrderService and injects the mocks by constructor (or by setter, or by field — in that priority order). Because Mockito resolves injection by type, make sure no two mocks share the same type.
Stubbing Behaviour with when…thenReturn
By default a mock returns zero-values (null, false, 0). Use when(mock.method(args)).thenReturn(value) to specify what it should return for a given call:
@Test
void placeOrder_savesAndReturnsOrder_whenStockAvailable() {
// Arrange
long productId = 42L;
int quantity = 3;
Order saved = new Order(productId, quantity);
saved.setId(1L);
when(inventoryClient.isAvailable(productId, quantity)).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenReturn(saved);
// Act
Order result = orderService.placeOrder(productId, quantity);
// Assert
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getProductId()).isEqualTo(productId);
verify(orderRepository, times(1)).save(any(Order.class));
}
any(Order.class) is an argument matcher — it accepts any non-null Order. Mockito ships with a wide library of matchers: eq(), anyString(), argThat(predicate), and more.
Testing Exception Paths
Every important branch deserves a test. When inventory is unavailable, the service should throw without touching the repository:
@Test
void placeOrder_throwsIllegalState_whenStockUnavailable() {
when(inventoryClient.isAvailable(anyLong(), anyInt())).thenReturn(false);
assertThatThrownBy(() -> orderService.placeOrder(42L, 5))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Insufficient stock");
verifyNoInteractions(orderRepository); // repository must not be called
}
@Test
void findById_throwsIllegalArgument_whenOrderMissing() {
when(orderRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> orderService.findById(99L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("99");
}
Use AssertJ's assertThatThrownBy instead of JUnit's assertThrows. AssertJ lets you chain additional assertions on the exception (.hasMessage, .hasCause, .hasMessageContaining) in one readable expression, while assertThrows only captures the type.
Verifying Interactions
Mockito records every call made on a mock. After your assertion you can verify that the service called a collaborator the right number of times with the right arguments:
// Passes if save() was called exactly once with any Order
verify(orderRepository, times(1)).save(any(Order.class));
// Passes if the client was never called
verifyNoInteractions(inventoryClient);
// Passes if findById was called with the exact ID
verify(orderRepository).findById(eq(1L));
Do not over-verify. Checking every single mock interaction makes tests brittle — they break when you refactor the implementation without changing the behaviour. Verify only the interactions that are part of the contract you are trying to guarantee.
Stubbing Void Methods and Throwing Exceptions
For void methods or when you need the mock to throw, use the alternative stubbing style:
// Stub a void method to throw
doThrow(new RuntimeException("DB unavailable"))
.when(orderRepository).deleteById(anyLong());
// Stub a void method to do nothing (already the default, but explicit)
doNothing().when(orderRepository).deleteById(anyLong());
// Stub to throw on a non-void method (alternative to thenThrow)
when(inventoryClient.isAvailable(anyLong(), anyInt()))
.thenThrow(new RuntimeException("Inventory service down"));
The AAA Pattern
Every test in this lesson follows Arrange – Act – Assert:
- Arrange — set up stubs and any input objects.
- Act — call exactly one method on the class under test.
- Assert — check the return value and/or verify interactions.
Keeping each section small and separated by a blank line makes tests easy to read at a glance.
Performance: Why This Is Fast
No Spring context is loaded, no database is touched, no HTTP connection is opened. A complete OrderServiceTest class with a dozen tests finishes in under 100 ms on any modern laptop. This speed encourages running tests on every save, giving you immediate feedback as you code.
Summary
Unit testing a Spring Boot service means instantiating it manually with mocked collaborators, stubbing the mocks to return controlled values, exercising one code path per test, and asserting both the return value and the interactions. JUnit 5 provides the test runner via @ExtendWith(MockitoExtension.class); Mockito provides @Mock, @InjectMocks, when…thenReturn, and verify. Keep tests small, name them clearly, and follow the AAA pattern — your future self and teammates will thank you.