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

UnitIntegration
ScopeOne class in isolationMultiple components together
DependenciesAll mockedReal or near-real
SpeedMillisecondsSeconds to minutes
ContextNo Spring contextSpring context loads
PurposeLogic correctnessComponent 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 needed
  • when().thenReturn() stubs return values; doThrow().when() stubs exceptions on void methods
  • verify() checks interactions; ArgumentCaptor inspects arguments passed to mocks
  • AssertJ’s assertThat() is fluent and readable — prefer it over JUnit’s assertEquals()
  • @ParameterizedTest eliminates duplicate test methods for multiple inputs
  • @Nested classes 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.