Course topics

By WebNest Studio

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 @Mock and @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

Java
@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();
}
Output
(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

Java
@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");
    }
}
Output
CouponServiceTest
  ✔ appliesPercentageDiscount()   (12 ms)
  ✔ rejectsExpiredCoupon()        (3 ms)
  ✔ rejectsUnknownCoupon()        (2 ms)
Tests run: 3, Failures: 0, Errors: 0

Parameterized tests and nested test classes

Java
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();
        }
    }
}
Output
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

Java
@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);
    }
}
Output
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.