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. This entry covers test structure, service testing with Mockito, parameterised tests, domain object testing, custom AssertJ assertions, common mistakes, and measuring coverage with JaCoCo.
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 name and package (OrderService → OrderServiceTest). JUnit 5, Mockito and AssertJ all come with spring-boot-starter-test. Note that the service below declares only the collaborators it actually uses: with constructor injection, every extra final field must also be passed in the test, so an unused dependency breaks compilation and signals a design smell.
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;
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 ─────────────────────────────────────────────────────────
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static com.example.TestFixtures.*;
class OrderServiceTest {
// ── Collaborators — mocked manually ───────────────────────────────
private final OrderRepository orderRepo =
mock(OrderRepository.class);
private final InventoryService inventoryService =
mock(InventoryService.class);
// ── Class under test — instantiated directly ──────────────────────
private final OrderService orderService =
new OrderService(orderRepo, inventoryService);
// ── Shared state (JUnit creates a new instance per test) ──────────
private final Long userId = 1L;
private final Long orderId = 42L;
private final Order order =
buildOrder(orderId, userId, OrderStatus.PENDING);
@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 every collaborator so tests are deterministic and fast, and test each branch: happy path, not-found, validation failures and boundaries. @ExtendWith(MockitoExtension.class) enables @Mock and @InjectMocks and turns on strict stubbing, which fails a test if you stub something that is never used — a useful guard against stale test setup. Use ArgumentCaptor when you need to inspect the object passed to a collaborator, such as verifying that a password was hashed before saving.
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 the password was hashed before saving
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_whenCurrentPasswordMatches() {
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. Give each case a readable display name so a failure report tells you which input broke. Note that this is written inside a JavaScript template string, so escape sequences such as tab and newline are written with a doubled backslash in the source.
Java
class ProductValidationTest {
private final ProductValidator validator = new ProductValidator();
// ── @ValueSource — simple single-value inputs ─────────────────────
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
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(name = "{1} customer pays {2} discount on {0}")
@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 — hold business rules that are worth testing directly, with no mocks at all. Verify invariants, state transitions and calculations on the object itself. In this example Order.create(userId, items) is the single factory that enforces the initial state; the Order.from(request, userId) used by the service is just a thin mapper that converts the request items and delegates to it, so there is only one place where a new order is built.
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 setup into factory methods in a shared TestFixtures class, and write custom AssertJ assertions for domain objects so failures read clearly and tests stay short. Give the custom entry point its own name (assertThatOrder) rather than overloading assertThat — otherwise a static import of both Assertions.assertThat and your own method becomes ambiguous. SoftAssertions.assertSoftly collects every failure in a block instead of stopping at the first, which is handy when checking many fields of one response.
Java
// ── Test data builders ────────────────────────────────────────────────
public class TestFixtures {
public static User buildUser(Long id, String email, String password) {
User user = new User();
user.setId(id);
user.setEmail(email);
user.setName("Test User");
user.setPassword(password);
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;
}
// Convenience overload used when only the status matters
public static Order buildOrder(OrderStatus status) {
return buildOrder(1L, 1L, status);
}
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);
}
// Distinct name avoids clashing with Assertions.assertThat
public static OrderAssert assertThatOrder(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);
assertThatOrder(response)
.hasStatus(OrderStatus.PENDING)
.belongsToUser(1L)
.hasTotalGreaterThan(BigDecimal.ZERO);
}
// ── Soft assertions — collect all failures ────────────────────────────
@Test
void userResponse_hasAllExpectedFields() {
UserResponse response = userService.findById(1L);
SoftAssertions.assertSoftly(softly -> {
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");
}); // reports ALL failures, not just the first
}Common Unit Testing Mistakes
Most unreliable test suites share the same few problems. Loading Spring (@SpringBootTest) for a class that has no Spring behaviour makes tests slow for no gain. Over-verifying every internal call ties tests to the implementation, so harmless refactors break them. Asserting only that no exception was thrown proves little. Mocking the class under test, or mocking simple value objects, hides real behaviour. Test names that describe the scenario and the expected outcome (method_expectedResult_whenCondition) make failures understandable without opening the test. Prefer asserting on results and state, and verify interactions only where the interaction is the requirement, such as "no email is sent when registration fails".
Java
// ❌ Slow: starts the whole Spring context for a plain class
@SpringBootTest
class DiscountServiceSlowTest {
@Autowired DiscountService service;
}
// ✅ Fast: plain Java, runs in milliseconds
class DiscountServiceTest {
private final DiscountService service = new DiscountService();
}
// ❌ Brittle: verifies implementation details, not behaviour
verify(orderRepo, times(1)).findByIdAndUserId(any(), any());
verify(orderRepo, never()).deleteAll();
verify(orderRepo, never()).count();
// ✅ Asserts the outcome that matters
assertThat(response.id()).isEqualTo(orderId);
// ❌ Proves almost nothing
assertThatCode(() -> service.process(order)).doesNotThrowAnyException();
// ✅ Proves the result and the side effect
OrderResponse result = service.process(order);
assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED);
verify(notificationService).sendConfirmation(order.getId());
// ❌ Mocking a simple value object
Money money = mock(Money.class);
// ✅ Use the real one
Money money = Money.of(new BigDecimal("10.00"), "USD");Measuring Coverage with JaCoCo
Coverage shows which code your tests never execute; it does not prove the tests are good. Use it to find untested branches, not as a target to hit at any cost. JaCoCo plugs into Maven, produces an HTML report under target/site/jacoco, and can fail the build when coverage drops below a threshold. A reasonable approach is to require high coverage on service and domain packages — where the business rules live — and exclude generated code, configuration classes, DTOs and the main application class. Run mvn verify and open target/site/jacoco/index.html to see line and branch coverage per class.
XML
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<configuration>
<excludes>
<exclude>**/config/**</exclude>
<exclude>**/dto/**</exclude>
<exclude>**/*Application.class</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
# Run tests, generate the report, enforce the thresholds
mvn clean verify
# Open the report
# target/site/jacoco/index.html