Spring Boot Tutorial
Unit Testing with JUnit and Mockito
Unit tests check one class in isolation, run in milliseconds, and need no Spring context, database or network. They are the foundation of a healthy test suite: a Spring Boot project typically has hundreds of unit tests, dozens of slice tests and a handful of full integration tests.
This lesson shows how to design Spring components so they are easy to unit-test, and how to write clear tests with JUnit Jupiter, Mockito and AssertJ — all of which come with Spring Boot's test starters. You will stub dependencies, verify interactions, capture arguments, test exceptions, write parameterized tests and control time.
Design for Testability: Constructor Injection
A service that receives its dependencies through the constructor can be created in a test with new OrderService(fakeRepo, fakeClock) — no Spring needed. Field injection with @Autowired on private fields forces you to start a Spring context or use reflection. Also inject things that make code non-deterministic — Clock, random generators, id generators — so tests can control them.
The Tools
Spring Boot's test starters bring the standard toolkit:
- JUnit Jupiter —
@Test,@BeforeEach,@Nested,@DisplayName,@ParameterizedTest. - Mockito —
mock(),when(...).thenReturn(...),verify(...),ArgumentCaptor; with@ExtendWith(MockitoExtension.class)you can use@Mockand@InjectMocks. - AssertJ — fluent assertions:
assertThat(list).hasSize(2).extracting(Order::id).containsExactly(1L, 2L). - Hamcrest and JSONassert — matchers and JSON comparison, used by some Spring test APIs.
Arrange, Act, Assert
Structure every test in three parts: arrange the inputs and stubs, act by calling one method, and assert the outcome. Name tests after behaviour (rejectsOrderWhenStockIsInsufficient), not after methods (testPlaceOrder2). Test one behaviour per test so a failure tells you exactly what broke.
Stubs vs Verification
Prefer asserting on results and state over verifying calls. Use verify for interactions that are the behaviour — "an email is sent", "the payment gateway is charged exactly once", "nothing is saved when validation fails". Over-verifying every call makes tests brittle: they break on harmless refactorings.
What Not to Mock
Do not mock value objects, records, collections or the class under test. Do not mock types you do not own in complex ways (for example chaining mocks of RestClient); wrap them in your own small interface or test them with a slice or integration test instead.
Examples
The class under test: a service with injected dependencies and a Clock
@Service
public class CouponService {
private final CouponRepository coupons;
private final Clock clock;
public CouponService(CouponRepository coupons, Clock clock) {
this.coupons = coupons;
this.clock = clock;
}
public BigDecimal apply(String code, BigDecimal total) {
Coupon coupon = coupons.findByCode(code)
.orElseThrow(() -> new InvalidCouponException("Unknown coupon " + code));
if (coupon.expiresOn().isBefore(LocalDate.now(clock))) {
throw new InvalidCouponException("Coupon " + code + " has expired");
}
BigDecimal discount = total.multiply(BigDecimal.valueOf(coupon.percent()))
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
coupons.markUsed(code);
return total.subtract(discount);
}
}
public record Coupon(String code, int percent, LocalDate expiresOn) {}
// Production Clock bean
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
(Because Clock and CouponRepository are constructor parameters, the test can pass in a fixed clock and a mock.)
Unit tests with Mockito, AssertJ and a fixed Clock
@ExtendWith(MockitoExtension.class)
class CouponServiceTest {
@Mock CouponRepository coupons;
Clock fixedClock = Clock.fixed(Instant.parse("2026-09-27T10:00:00Z"), ZoneOffset.UTC);
CouponService service;
@BeforeEach
void setUp() {
service = new CouponService(coupons, fixedClock);
}
@Test
void appliesPercentageDiscount() {
when(coupons.findByCode("WELCOME10"))
.thenReturn(Optional.of(new Coupon("WELCOME10", 10, LocalDate.of(2026, 12, 31))));
BigDecimal result = service.apply("WELCOME10", new BigDecimal("2999.00"));
assertThat(result).isEqualByComparingTo("2699.10");
verify(coupons).markUsed("WELCOME10");
}
@Test
void rejectsExpiredCoupon() {
when(coupons.findByCode("SUMMER"))
.thenReturn(Optional.of(new Coupon("SUMMER", 20, LocalDate.of(2026, 8, 31))));
assertThatThrownBy(() -> service.apply("SUMMER", new BigDecimal("1000")))
.isInstanceOf(InvalidCouponException.class)
.hasMessageContaining("expired");
verify(coupons, never()).markUsed(anyString());
}
@Test
void rejectsUnknownCoupon() {
when(coupons.findByCode("NOPE")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.apply("NOPE", BigDecimal.TEN))
.isInstanceOf(InvalidCouponException.class)
.hasMessage("Unknown coupon NOPE");
}
}
CouponServiceTest
✔ appliesPercentageDiscount() (12 ms)
✔ rejectsExpiredCoupon() (3 ms)
✔ rejectsUnknownCoupon() (2 ms)
Tests run: 3, Failures: 0, Errors: 0
Parameterized tests and nested test classes
class PasswordPolicyTest {
PasswordPolicy policy = new PasswordPolicy(12);
@ParameterizedTest(name = "\"{0}\" is rejected")
@ValueSource(strings = {"", "short", "elevenchars", " "})
void rejectsWeakPasswords(String password) {
assertThat(policy.isAcceptable(password)).isFalse();
}
@ParameterizedTest
@CsvSource({
"correct-horse-battery, true",
"Tr0ub4dor&3xyz, true",
"aaaaaaaaaaaaaaaa, false" // repeated characters are rejected
})
void evaluatesPasswords(String password, boolean expected) {
assertThat(policy.isAcceptable(password)).isEqualTo(expected);
}
@Nested
@DisplayName("when the password contains the username")
class ContainsUsername {
@Test
void isRejected() {
assertThat(policy.isAcceptable("asha-password-2026", "asha")).isFalse();
}
}
}
PasswordPolicyTest
✔ "" is rejected
✔ "short" is rejected
✔ "elevenchars" is rejected
✔ " " is rejected
✔ evaluatesPasswords(correct-horse-battery, true)
✔ evaluatesPasswords(Tr0ub4dor&3xyz, true)
✔ evaluatesPasswords(aaaaaaaaaaaaaaaa, false)
when the password contains the username
✔ isRejected()
Capturing arguments to check what was saved or sent
@ExtendWith(MockitoExtension.class)
class RegistrationServiceTest {
@Mock UserRepository users;
@Mock MailSender mail;
@InjectMocks RegistrationService service; // Mockito calls the constructor with the mocks
@Captor ArgumentCaptor<User> userCaptor;
@Test
void savesNormalisedEmailAndSendsWelcomeMail() {
when(users.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.register(" Asha@Webnest.IN ", "Asha");
verify(users).save(userCaptor.capture());
assertThat(userCaptor.getValue().getEmail()).isEqualTo("asha@webnest.in");
verify(mail).sendWelcome("asha@webnest.in", "Asha");
verifyNoMoreInteractions(mail);
}
}
RegistrationServiceTest > savesNormalisedEmailAndSendsWelcomeMail() PASSED
Common Mistakes
- Starting a full @SpringBootTest to test plain business logic that could be a millisecond unit test.
- Using field injection, which makes classes impossible to construct in unit tests without Spring.
- Calling LocalDate.now() or Instant.now() directly, producing tests that fail on certain dates or time zones.
- Verifying every single mock interaction, so tests break whenever the implementation is refactored.
- Comparing BigDecimal with isEqualTo, which fails for 2699.10 vs 2699.1; use isEqualByComparingTo.
Key Points to Remember
- Constructor injection lets you create any Spring component in a test with plain new.
- JUnit Jupiter, Mockito and AssertJ are included in Spring Boot's test starters.
- Follow Arrange-Act-Assert and name tests after behaviour.
- Inject Clock and other sources of non-determinism so tests are repeatable.
- Prefer asserting results; verify only interactions that are part of the behaviour.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.