Spring Boot
Unit Testing
Unit tests verify a single class in isolation — no Spring context, no database, no network. They are the fastest tests in the pyramid and should make up the majority of the test suite. Spring Boot's test support provides slices, utilities, and auto-configuration, but unit tests deliberately avoid all of it. This entry covers test structure, service testing with Mockito, assertion styles, parameterised tests, exception testing, and coverage strategies.
Unit Test Structure and Setup
A unit test instantiates the class under test directly, supplies mocked collaborators, and asserts the outcome. No @SpringBootTest, no @Autowired — just plain Java. The test class mirrors the production class in naming and package structure. JUnit 5 and AssertJ are included in spring-boot-starter-test automatically.
Java
<!-- No extra dependencies needed — spring-boot-starter-test includes:
JUnit 5, Mockito, AssertJ, Hamcrest, JSONassert, JsonPath -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
// ── Class under test ──────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
private final InventoryService inventoryService;
private final PasswordEncoder passwordEncoder;
public OrderResponse create(CreateOrderRequest request,
Long userId) {
inventoryService.reserve(request.items());
Order order = Order.from(request, userId);
return OrderResponse.from(orderRepo.save(order));
}
public OrderResponse findById(Long id, Long userId) {
return orderRepo.findByIdAndUserId(id, userId)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
// ── Unit test ─────────────────────────────────────────────────────────
class OrderServiceTest {
// ── Collaborators — mocked manually or via @Mock ──────────────────
private OrderRepository orderRepo =
mock(OrderRepository.class);
private InventoryService inventoryService =
mock(InventoryService.class);
// ── Class under test — instantiated directly ──────────────────────
private OrderService orderService =
new OrderService(orderRepo, inventoryService);
// ── Arrange shared state ──────────────────────────────────────────
private final Long userId = 1L;
private final Long orderId = 42L;
private final Order order = buildOrder(orderId, userId);
@Test
void create_savesOrderAndReturnsResponse() {
// Arrange
CreateOrderRequest request = buildCreateRequest();
when(orderRepo.save(any(Order.class))).thenReturn(order);
// Act
OrderResponse response =
orderService.create(request, userId);
// Assert
assertThat(response.id()).isEqualTo(orderId);
assertThat(response.userId()).isEqualTo(userId);
verify(inventoryService).reserve(request.items());
verify(orderRepo).save(any(Order.class));
}
@Test
void findById_returnsOrder_whenOwnerRequests() {
when(orderRepo.findByIdAndUserId(orderId, userId))
.thenReturn(Optional.of(order));
OrderResponse response =
orderService.findById(orderId, userId);
assertThat(response.id()).isEqualTo(orderId);
}
@Test
void findById_throws_whenOrderNotFound() {
when(orderRepo.findByIdAndUserId(orderId, userId))
.thenReturn(Optional.empty());
assertThatThrownBy(() ->
orderService.findById(orderId, userId))
.isInstanceOf(OrderNotFoundException.class)
.hasMessageContaining(String.valueOf(orderId));
}
}Testing Service Layer Logic
Service tests focus on business rules — not persistence, not HTTP. Mock all collaborators so tests are deterministic and fast. Test every branch: happy path, not-found, validation failures, and boundary conditions. The Arrange-Act-Assert pattern keeps each test focused and readable.
Java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepo;
@Mock private PasswordEncoder encoder;
@Mock private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void register_createsUser_withHashedPassword() {
// Arrange
RegisterRequest request = new RegisterRequest(
"Alice", "alice@example.com", "SecurePass123!");
String hash = "$2a$12$hashed";
when(userRepo.existsByEmail("alice@example.com"))
.thenReturn(false);
when(encoder.encode("SecurePass123!")).thenReturn(hash);
when(userRepo.save(any(User.class)))
.thenAnswer(inv -> {
User u = inv.getArgument(0);
u.setId(1L);
return u;
});
// Act
UserResponse response = userService.register(request);
// Assert
assertThat(response.email())
.isEqualTo("alice@example.com");
assertThat(response.name()).isEqualTo("Alice");
// Verify password was hashed
ArgumentCaptor<User> userCaptor =
ArgumentCaptor.forClass(User.class);
verify(userRepo).save(userCaptor.capture());
assertThat(userCaptor.getValue().getPassword())
.isEqualTo(hash)
.doesNotContain("SecurePass123!");
// Verify welcome email was sent
verify(emailService).sendWelcome("alice@example.com");
}
@Test
void register_throws_whenEmailAlreadyExists() {
when(userRepo.existsByEmail("alice@example.com"))
.thenReturn(true);
assertThatThrownBy(() ->
userService.register(new RegisterRequest(
"Alice", "alice@example.com", "pass")))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("alice@example.com");
verify(userRepo, never()).save(any());
verify(emailService, never()).sendWelcome(any());
}
@Test
void changePassword_updatesHash_andRevokesTokens() {
User user = buildUser(1L, "alice@example.com",
"$2a$12$oldHash");
when(userRepo.findById(1L))
.thenReturn(Optional.of(user));
when(encoder.matches("oldPass", "$2a$12$oldHash"))
.thenReturn(true);
when(encoder.encode("newPass"))
.thenReturn("$2a$12$newHash");
userService.changePassword(1L,
new ChangePasswordRequest("oldPass", "newPass"));
ArgumentCaptor<User> captor =
ArgumentCaptor.forClass(User.class);
verify(userRepo).save(captor.capture());
assertThat(captor.getValue().getPassword())
.isEqualTo("$2a$12$newHash");
}
@Test
void changePassword_throws_whenCurrentPasswordWrong() {
User user = buildUser(1L, "alice@example.com",
"$2a$12$oldHash");
when(userRepo.findById(1L))
.thenReturn(Optional.of(user));
when(encoder.matches("wrongPass", "$2a$12$oldHash"))
.thenReturn(false);
assertThatThrownBy(() ->
userService.changePassword(1L,
new ChangePasswordRequest("wrongPass", "new")))
.isInstanceOf(InvalidPasswordException.class);
verify(userRepo, never()).save(any());
}
}Parameterised Tests
Parameterised tests run the same logic with multiple inputs without duplicating test methods. JUnit 5 provides @ValueSource, @CsvSource, @MethodSource, and @EnumSource. Use them for boundary conditions, validation rules, and any logic that branches on input values.
Java
class ProductValidationTest {
private final ProductValidator validator = new ProductValidator();
// ── @ValueSource — simple single-value inputs ─────────────────────
@ParameterizedTest
@ValueSource(strings = {"", " ", " ", "
"})
void validate_throws_whenNameIsBlank(String name) {
assertThatThrownBy(() ->
validator.validate(new CreateProductRequest(
name, BigDecimal.TEN)))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("name");
}
// ── @CsvSource — multiple columns ────────────────────────────────
@ParameterizedTest(name = "price={0} → valid={1}")
@CsvSource({
"0.01, true",
"1.00, true",
"9999.99, true",
"0.00, false",
"-1.00, false",
"10000.00, false"
})
void validate_priceRange(BigDecimal price, boolean expectedValid) {
CreateProductRequest request =
new CreateProductRequest("Widget", price);
if (expectedValid) {
assertThatCode(() ->
validator.validate(request))
.doesNotThrowAnyException();
} else {
assertThatThrownBy(() ->
validator.validate(request))
.isInstanceOf(ValidationException.class);
}
}
// ── @MethodSource — complex objects ───────────────────────────────
@ParameterizedTest
@MethodSource("discountScenarios")
void calculateDiscount_appliesCorrectRate(
BigDecimal price,
String customerTier,
BigDecimal expectedDiscount) {
DiscountService discountService = new DiscountService();
BigDecimal actual = discountService.calculate(
price, customerTier);
assertThat(actual)
.isEqualByComparingTo(expectedDiscount);
}
static Stream<Arguments> discountScenarios() {
return Stream.of(
Arguments.of(new BigDecimal("100"), "SILVER",
new BigDecimal("5.00")),
Arguments.of(new BigDecimal("100"), "GOLD",
new BigDecimal("10.00")),
Arguments.of(new BigDecimal("100"), "PLATINUM",
new BigDecimal("15.00")),
Arguments.of(new BigDecimal("100"), "STANDARD",
BigDecimal.ZERO)
);
}
// ── @EnumSource — all enum values ─────────────────────────────────
@ParameterizedTest
@EnumSource(OrderStatus.class)
void orderStatus_hasNonBlankDisplayName(OrderStatus status) {
assertThat(status.getDisplayName())
.isNotBlank()
.doesNotStartWith(" ")
.doesNotEndWith(" ");
}
// ── @EnumSource with filter ───────────────────────────────────────
@ParameterizedTest
@EnumSource(value = OrderStatus.class,
names = {"SHIPPED", "DELIVERED"},
mode = EnumSource.Mode.INCLUDE)
void completedStatuses_cannotBeModified(OrderStatus status) {
Order order = buildOrder(status);
assertThat(order.canBeModified()).isFalse();
}
}Testing Domain Objects
Domain objects — entities, value objects, and aggregates — encapsulate business rules that are worth testing directly without mocks. Test the domain in isolation from persistence and HTTP. Verify invariants, state transitions, and business logic that lives on the domain object itself.
Java
class OrderTest {
@Test
void newOrder_hasCorrectInitialState() {
Order order = Order.create(1L,
List.of(new OrderItem(101L, 2, new BigDecimal("9.99"))));
assertThat(order.getStatus())
.isEqualTo(OrderStatus.PENDING);
assertThat(order.getTotal())
.isEqualByComparingTo(new BigDecimal("19.98"));
assertThat(order.getItems()).hasSize(1);
assertThat(order.getPlacedAt()).isNotNull()
.isBeforeOrEqualTo(Instant.now());
}
@Test
void ship_transitionsToShipped_whenConfirmed() {
Order order = buildOrder(OrderStatus.CONFIRMED);
order.ship("TRACK-001");
assertThat(order.getStatus())
.isEqualTo(OrderStatus.SHIPPED);
assertThat(order.getTrackingNumber())
.isEqualTo("TRACK-001");
assertThat(order.getShippedAt()).isNotNull();
}
@Test
void ship_throws_whenNotConfirmed() {
Order order = buildOrder(OrderStatus.PENDING);
assertThatThrownBy(() -> order.ship("TRACK-001"))
.isInstanceOf(BusinessRuleException.class)
.hasMessageContaining("CONFIRMED");
}
@Test
void cancel_throws_whenAlreadyShipped() {
Order order = buildOrder(OrderStatus.SHIPPED);
assertThatThrownBy(order::cancel)
.isInstanceOf(BusinessRuleException.class);
}
// ── Value object equality ─────────────────────────────────────────
@Test
void money_equality_basedOnAmountAndCurrency() {
Money a = Money.of(new BigDecimal("10.00"), "USD");
Money b = Money.of(new BigDecimal("10.00"), "USD");
Money c = Money.of(new BigDecimal("10.00"), "EUR");
assertThat(a).isEqualTo(b);
assertThat(a).isNotEqualTo(c);
}
@Test
void money_add_sumsBothAmounts() {
Money a = Money.of(new BigDecimal("10.00"), "USD");
Money b = Money.of(new BigDecimal("5.50"), "USD");
Money result = a.add(b);
assertThat(result.getAmount())
.isEqualByComparingTo(new BigDecimal("15.50"));
}
@Test
void money_add_throws_whenCurrenciesDiffer() {
Money usd = Money.of(BigDecimal.TEN, "USD");
Money eur = Money.of(BigDecimal.TEN, "EUR");
assertThatThrownBy(() -> usd.add(eur))
.isInstanceOf(CurrencyMismatchException.class);
}
}Test Utilities and Custom Assertions
Extract repeated test setup into factory methods, builder helpers, and a shared test fixture. Write custom AssertJ assertions for domain objects to produce readable failure messages and keep test methods clean. Group related test utilities in a dedicated class or test base.
Java
// ── Test data builders ────────────────────────────────────────────────
public class TestFixtures {
public static User buildUser(Long id, String email) {
User user = new User();
user.setId(id);
user.setEmail(email);
user.setName("Test User");
user.setPassword("$2a$12$hashed");
user.setEnabled(true);
user.setRoles(Set.of("USER"));
user.setCreatedAt(LocalDateTime.now());
return user;
}
public static Order buildOrder(Long id, Long userId,
OrderStatus status) {
Order order = new Order();
order.setId(id);
order.setUserId(userId);
order.setStatus(status);
order.setTotal(new BigDecimal("99.99"));
order.setPlacedAt(Instant.now());
return order;
}
public static CreateOrderRequest buildCreateRequest() {
return new CreateOrderRequest(
List.of(new OrderItemRequest(1L, 2, new BigDecimal("9.99"))),
"123 Main St", "USD");
}
}
// ── Custom AssertJ assertion ───────────────────────────────────────────
public class OrderAssert
extends AbstractAssert<OrderAssert, OrderResponse> {
public OrderAssert(OrderResponse actual) {
super(actual, OrderAssert.class);
}
public static OrderAssert assertThat(OrderResponse order) {
return new OrderAssert(order);
}
public OrderAssert hasStatus(OrderStatus expected) {
isNotNull();
if (!actual.status().equals(expected)) {
failWithMessage(
"Expected order status <%s> but was <%s>",
expected, actual.status());
}
return this;
}
public OrderAssert belongsToUser(Long userId) {
isNotNull();
if (!actual.userId().equals(userId)) {
failWithMessage(
"Expected order to belong to user <%d> but was <%d>",
userId, actual.userId());
}
return this;
}
public OrderAssert hasTotalGreaterThan(BigDecimal min) {
isNotNull();
if (actual.total().compareTo(min) <= 0) {
failWithMessage(
"Expected total > <%s> but was <%s>",
min, actual.total());
}
return this;
}
}
// ── Usage in tests ────────────────────────────────────────────────────
@Test
void create_returnsCorrectOrder() {
OrderResponse response = orderService.create(
buildCreateRequest(), 1L);
// Fluent custom assertion
assertThat(response)
.hasStatus(OrderStatus.PENDING)
.belongsToUser(1L)
.hasTotalGreaterThan(BigDecimal.ZERO);
}
// ── Soft assertions — collect all failures ────────────────────────────
@Test
void userResponse_hasAllExpectedFields() {
UserResponse response = userService.findById(1L);
SoftAssertions softly = new SoftAssertions();
softly.assertThat(response.id())
.isEqualTo(1L);
softly.assertThat(response.email())
.isEqualTo("alice@example.com");
softly.assertThat(response.name())
.isEqualTo("Alice");
softly.assertThat(response.roles())
.containsExactlyInAnyOrder("USER");
softly.assertAll(); // reports ALL failures, not just the first
}