Testing Spring Boot Apps: Unit Tests with JUnit 5 and Mockito
Good tests catch regressions, document behavior, and give you confidence to refactor. Bad tests slow you down. This article covers unit testing at the service layer — fast, focused, no Spring context needed.
Setup
<!-- spring-boot-starter-test includes JUnit 5, Mockito, AssertJ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
This pulls in:
- JUnit 5 (Jupiter) — test runner and assertions
- Mockito — mocking framework
- AssertJ — fluent assertions (
assertThat(...)) - Hamcrest — matcher library
- MockMvc — web layer testing (next article)
- Testcontainers integration
Unit Tests vs Integration Tests
| Unit | Integration | |
|---|---|---|
| Scope | One class in isolation | Multiple components together |
| Dependencies | All mocked | Real or near-real |
| Speed | Milliseconds | Seconds to minutes |
| Context | No Spring context | Spring context loads |
| Purpose | Logic correctness | Component interaction |
Start with unit tests. Add integration tests for the boundaries (DB, HTTP, messaging).
Your First Unit Test
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private EmailService emailService;
@Mock
private InventoryService inventoryService;
@InjectMocks
private OrderService orderService; // constructor injection via Mockito
@Test
void shouldCreateOrderSuccessfully() {
// Arrange
var request = new CreateOrderRequest(
UUID.randomUUID(),
List.of(new OrderItemRequest(UUID.randomUUID(), 2, BigDecimal.valueOf(49.99)))
);
var savedOrder = Order.builder()
.id(UUID.randomUUID())
.status(OrderStatus.PENDING)
.build();
when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
doNothing().when(inventoryService).reserve(anyList());
// Act
Order result = orderService.createOrder(request);
// Assert
assertThat(result).isNotNull();
assertThat(result.getId()).isNotNull();
assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
@ExtendWith(MockitoExtension.class) — enables Mockito annotations@Mock — creates a mock@InjectMocks — creates the class under test with mocks injected via constructor
JUnit 5 Assertions
// Basic
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(order.getId()).isNotNull();
assertThat(order.getItems()).hasSize(2);
assertThat(order.getTotalAmount()).isEqualByComparingTo(BigDecimal.valueOf(99.98));
// Collections
assertThat(orders).isNotEmpty();
assertThat(orders).hasSize(3);
assertThat(orders).extracting(Order::getStatus).containsOnly(OrderStatus.PENDING);
assertThat(orders).filteredOn(o -> o.getCustomerId().equals(customerId)).hasSize(2);
// Exceptions
assertThatThrownBy(() -> orderService.createOrder(null))
.isInstanceOf(NullPointerException.class);
assertThatThrownBy(() -> orderService.findById(UUID.randomUUID()))
.isInstanceOf(OrderNotFoundException.class)
.hasMessageContaining("not found");
// Or with JUnit 5
OrderNotFoundException ex = assertThrows(
OrderNotFoundException.class,
() -> orderService.findById(UUID.randomUUID())
);
assertThat(ex.getMessage()).contains("not found");
// Soft assertions (report all failures at once)
SoftAssertions.assertSoftly(soft -> {
soft.assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
soft.assertThat(order.getCustomerId()).isEqualTo(customerId);
soft.assertThat(order.getItems()).hasSize(2);
// All three are checked — doesn't stop at first failure
});
Mockito: Stub, Verify, Capture
Stubbing return values
// Return a value
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
// Throw an exception
when(orderRepository.findById(unknownId))
.thenThrow(new OrderNotFoundException(unknownId));
// Return different values on consecutive calls
when(generator.nextOrderNumber())
.thenReturn("ORD-001")
.thenReturn("ORD-002")
.thenReturn("ORD-003");
// Return based on argument
when(orderRepository.findById(any(UUID.class)))
.thenAnswer(invocation -> {
UUID id = invocation.getArgument(0);
return id.equals(knownId) ? Optional.of(order) : Optional.empty();
});
// Void method that throws
doThrow(new InventoryException("Out of stock"))
.when(inventoryService).reserve(anyList());
// Void method that does nothing (this is the default for void methods)
doNothing().when(emailService).sendConfirmation(any(Order.class));
Verifying interactions
// Verify a method was called
verify(emailService).sendConfirmation(any(Order.class));
// Verify call count
verify(orderRepository, times(1)).save(any(Order.class));
verify(emailService, never()).sendFailureNotification(any());
// Verify no other interactions happened
verifyNoMoreInteractions(orderRepository, emailService);
// Verify order of calls
InOrder inOrder = inOrder(inventoryService, orderRepository, emailService);
inOrder.verify(inventoryService).reserve(anyList());
inOrder.verify(orderRepository).save(any());
inOrder.verify(emailService).sendConfirmation(any());
ArgumentCaptor — inspect what was passed
@Test
void shouldSaveOrderWithCorrectStatus() {
var request = createRequest();
when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));
orderService.createOrder(request);
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(orderRepository).save(captor.capture());
Order capturedOrder = captor.getValue();
assertThat(capturedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(capturedOrder.getCustomerId()).isEqualTo(request.customerId());
assertThat(capturedOrder.getItems()).hasSize(request.items().size());
}
Testing Exception Scenarios
@Test
void shouldThrowWhenOrderNotFound() {
UUID unknownId = UUID.randomUUID();
when(orderRepository.findById(unknownId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> orderService.findById(unknownId))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("Order not found: " + unknownId);
verify(emailService, never()).sendConfirmation(any());
}
@Test
void shouldNotSaveOrderWhenInventoryFails() {
var request = createValidRequest();
doThrow(new InsufficientInventoryException(productId, 5, 2))
.when(inventoryService).reserve(anyList());
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(InsufficientInventoryException.class);
verify(orderRepository, never()).save(any());
}
Parameterized Tests
Test one method with multiple inputs:
@ParameterizedTest
@ValueSource(ints = {1, 5, 50, 99})
void shouldAcceptValidQuantities(int quantity) {
var request = CreateOrderRequest.withQuantity(quantity);
// should not throw
assertThatCode(() -> orderService.validateRequest(request))
.doesNotThrowAnyException();
}
@ParameterizedTest
@ValueSource(ints = {0, -1, -100, 1000})
void shouldRejectInvalidQuantities(int quantity) {
var request = CreateOrderRequest.withQuantity(quantity);
assertThatThrownBy(() -> orderService.validateRequest(request))
.isInstanceOf(InvalidRequestException.class);
}
@ParameterizedTest
@CsvSource({
"PENDING, true",
"CONFIRMED, true",
"SHIPPED, false",
"DELIVERED, false",
"CANCELLED, false"
})
void canBeCancelledDependsOnStatus(OrderStatus status, boolean expected) {
Order order = Order.builder().status(status).build();
assertThat(order.canBeCancelled()).isEqualTo(expected);
}
@ParameterizedTest
@MethodSource("orderStatusTransitions")
void shouldApplyValidStatusTransition(OrderStatus from, OrderStatus to) {
Order order = Order.builder().status(from).build();
order.transitionTo(to);
assertThat(order.getStatus()).isEqualTo(to);
}
static Stream<Arguments> orderStatusTransitions() {
return Stream.of(
Arguments.of(OrderStatus.PENDING, OrderStatus.CONFIRMED),
Arguments.of(OrderStatus.CONFIRMED, OrderStatus.SHIPPED),
Arguments.of(OrderStatus.SHIPPED, OrderStatus.DELIVERED)
);
}
@Nested — Organizing Tests by Scenario
class OrderServiceTest {
@Nested
@DisplayName("createOrder")
class CreateOrder {
@Test
@DisplayName("saves order when inventory is available")
void savesOrderWhenInventoryAvailable() { ... }
@Test
@DisplayName("throws when customer not found")
void throwsWhenCustomerNotFound() { ... }
@Nested
@DisplayName("with promo code")
class WithPromoCode {
@Test
@DisplayName("applies discount when code is valid")
void appliesDiscountForValidCode() { ... }
@Test
@DisplayName("ignores discount when code is expired")
void ignoresExpiredCode() { ... }
}
}
@Nested
@DisplayName("cancelOrder")
class CancelOrder {
@Test
@DisplayName("cancels pending orders")
void cancelsPendingOrder() { ... }
@Test
@DisplayName("throws when order is already shipped")
void throwsForShippedOrder() { ... }
}
}
Test Fixtures
Avoid duplicating test data setup with fixture methods or builder helpers:
class OrderServiceTest {
// Shared fixtures — build real objects, not mocks
private CreateOrderRequest validRequest() {
return new CreateOrderRequest(
UUID.randomUUID(),
List.of(new OrderItemRequest(UUID.randomUUID(), 2, BigDecimal.valueOf(49.99))),
new ShippingAddressRequest("123 Main St", null, "London", "GB", "EC1A1BB")
);
}
private Order pendingOrder(UUID customerId) {
return Order.builder()
.id(UUID.randomUUID())
.customerId(customerId)
.status(OrderStatus.PENDING)
.totalAmount(BigDecimal.valueOf(99.98))
.createdAt(Instant.now())
.build();
}
}
For complex objects, use a builder pattern or a dedicated TestData class:
public class TestData {
public static Order.Builder anOrder() {
return Order.builder()
.id(UUID.randomUUID())
.customerId(UUID.randomUUID())
.status(OrderStatus.PENDING)
.orderNumber("ORD-TEST-001")
.totalAmount(BigDecimal.valueOf(99.99))
.createdAt(Instant.now());
}
}
// Usage
Order order = TestData.anOrder().status(OrderStatus.CONFIRMED).build();
What to Test and What Not to Test
Test:
- Business logic in service methods
- Edge cases and boundary conditions
- All paths through conditional logic
- Exception scenarios
- Validation rules
Don’t unit test:
- Framework glue code (Spring wiring)
- Simple getters/setters
- DTOs with no logic
- Trivial one-liners that delegate to the framework
What You’ve Learned
@ExtendWith(MockitoExtension.class)enables Mockito — no Spring context neededwhen().thenReturn()stubs return values;doThrow().when()stubs exceptions on void methodsverify()checks interactions;ArgumentCaptorinspects arguments passed to mocks- AssertJ’s
assertThat()is fluent and readable — prefer it over JUnit’sassertEquals() @ParameterizedTesteliminates duplicate test methods for multiple inputs@Nestedclasses organize related tests — produce readable test reports
Next: Article 30 — Testing the Repository Layer with @DataJpaTest — test queries against a real database without starting the full application.