Opening Hook
When your Spring Boot application grows, test suites become a bottleneck: starting the container for every single unit test wastes seconds that multiply across CI pipelines. Unit testing with JUnit 5 and Mockito: a practical guide teaches you how to test business logic at lightning speed, in isolation, without touching the framework or the database. We've seen teams cut their test execution time by 80% and catch regressions faster by focusing on what actually matters—your domain logic.
Plain Constructors: The Foundation of Testable Design
The first principle of fast unit tests is making your classes independent of Spring. Constructor injection gives you that freedom. When a service accepts its dependencies through a constructor, you can instantiate it in a test without any framework magic—just call new.
Consider a pricing service that needs a tax engine. In production, Spring wires them together. In tests, you control it:
public class PricingService {
private final TaxService taxService;
private final DiscountEngine discountEngine;
public PricingService(TaxService taxService, DiscountEngine discountEngine) {
this.taxService = taxService;
this.discountEngine = discountEngine;
}
public BigDecimal total(BigDecimal subtotal, String country) {
BigDecimal taxRate = taxService.rate(country);
BigDecimal discount = discountEngine.calculate(subtotal);
return subtotal
.subtract(discount)
.multiply(BigDecimal.ONE.add(taxRate));
}
}Constructor injection is testable design. You avoid setters that allow half-constructed objects, and you avoid service locators that hide dependencies. When a new team member reads the constructor signature, they immediately see what the service needs.
Mock Collaborators: Stub Only What You Need
The second principle is isolation. Your unit tests should focus on one class's behavior, not on whether the entire dependency chain works. That's what integration tests are for. Mockito lets you replace real collaborators with stubs that return canned responses.
Here's where JUnit 5's @ExtendWith(MockitoExtension.class) comes in. It activates Mockito annotations in your test class, so you can mark fields with @Mock and @InjectMocks and Mockito wires them up automatically:
@ExtendWith(MockitoExtension.class)
class PricingServiceTest {
@Mock
TaxService taxService;
@Mock
DiscountEngine discountEngine;
@InjectMocks
PricingService pricingService;
@Test
void totalIncludesTaxAndDiscount() {
// Arrange: Tell mocks what to return
when(taxService.rate("LK")).thenReturn(new BigDecimal("0.18"));
when(discountEngine.calculate(new BigDecimal("100")))
.thenReturn(new BigDecimal("10"));
// Act
BigDecimal result = pricingService.total(new BigDecimal("100"), "LK");
// Assert
assertThat(result)
.isEqualByComparingTo(new BigDecimal("161.20"));
}
}Notice what happened: we never started Spring. We never created real database connections or called real tax APIs. The test runs in milliseconds. Mockito's when().thenReturn() chain tells the mock: "When someone calls this method with these arguments, return this value."
Don't mock everything, though. Only mock external dependencies—things the class depends on but doesn't own. In this case, TaxService and DiscountEngine are external; they live in different packages or come from third-party libraries. Your own value objects—BigDecimal, your domain model—should be real. The goal is to verify that your class uses its collaborators correctly, not to test the collaborators themselves.
Test Behavior: Assert Outputs and Side Effects
A common trap is writing tests that verify method calls instead of outcomes. For example, checking that your code called taxService.rate() three times. That's testing implementation, not behavior. If you refactor the code to call it twice and cache the result, your test breaks even though the behavior—the final price—is still correct.
Instead, assert the outputs your class produces and the side effects your class causes. In the pricing example, the output is a BigDecimal total. That's what we assert:
@Test
void totalIncludesTaxAndDiscount() {
when(taxService.rate("LK")).thenReturn(new BigDecimal("0.18"));
when(discountEngine.calculate(any())).thenReturn(new BigDecimal("10"));
BigDecimal result = pricingService.total(new BigDecimal("100"), "LK");
// Assert the outcome, not the calls
assertThat(result).isEqualByComparingTo(new BigDecimal("161.20"));
}
@Test
void throwsWhenTaxServiceFails() {
when(taxService.rate("XX")).thenThrow(new CountryNotSupportedException());
assertThatThrownBy(
() -> pricingService.total(new BigDecimal("100"), "XX")
).isInstanceOf(CountryNotSupportedException.class);
}If you need to verify that a mock was called (for example, in event publishing), use verify(), but sparingly:
@Mock
EventPublisher eventPublisher;
@Test
void publishesOrderCreatedEvent() {
when(taxService.rate("LK")).thenReturn(new BigDecimal("0.18"));
pricingService.total(new BigDecimal("100"), "LK");
verify(eventPublisher).publish(argThat(event ->
event instanceof PricingCalculated
));
}This test still focuses on behavior: when you calculate a total, an event is published. We just use verify() to confirm the side effect happened.
Parameterized Tests: Cover Edge Cases Efficiently
Edge cases are where bugs hide. Negative numbers, zero, boundary values, nulls. Writing one test per case bloats your test class. JUnit 5's @ParameterizedTest lets you run the same test logic with different inputs:
@ParameterizedTest
@CsvSource({
"100, LK, 0.18, 10, 161.20",
"50, US, 0.07, 0, 53.50",
"1000, SG, 0.07, 100, 963.00",
"0, GB, 0.20, 0, 0.00"
})
void totalWithVariousInputs(
BigDecimal subtotal,
String country,
BigDecimal taxRate,
BigDecimal discount,
BigDecimal expected) {
when(taxService.rate(country)).thenReturn(taxRate);
when(discountEngine.calculate(subtotal)).thenReturn(discount);
BigDecimal result = pricingService.total(subtotal, country);
assertThat(result).isEqualByComparingTo(expected);
}Each row in @CsvSource becomes a separate test execution. You get better coverage, clearer documentation of edge cases, and less boilerplate. If one row fails, the test report shows exactly which input set caused the problem.
You can also use @ValueSource for single parameters, @MethodSource for complex objects, or @CsvFileSource for large data sets loaded from a file. The flexibility means you can test your happy path and your sad paths without duplicating code.
Common Mistakes
- Mocking the class under test: Never use
@Mockon the service you're testing. Use@InjectMocksso Mockito creates a real instance and injects mocks into it. - Over-mocking: Don't mock primitive types, collections, or your own value objects. Mock only external services. If you're mocking five things just to test one method, your class is too coupled.
- Testing implementation instead of behavior: Avoid tests like "verify this method was called exactly twice." Those tests break during refactoring. Assert outputs instead.
- Ignoring test readability: Use descriptive test names and follow the Arrange-Act-Assert pattern. Other developers should understand what the test checks without reading the entire method body.
- Not testing error paths: Happy-path tests are easy. Test what happens when the tax service times out, when the discount is invalid, when the country code is unknown. That's where bugs live.
Key Takeaways
- Use constructor injection to make classes testable without Spring. Just call
newin your test. - Mock only external collaborators with
@Mockand wire them with@InjectMocks. Keep tests fast and focused. - Assert outcomes and side effects, not method calls. This keeps tests resilient to refactoring.
- Use
@ParameterizedTestwith@CsvSourceto cover edge cases efficiently and document expected behavior. - Test error paths and boundary conditions with the same rigor as happy paths.
- Fast unit tests give you confidence to refactor. Slow integration tests are valuable, but not as your primary feedback loop.






Comments
Loading comments…