The fastest test is the one that doesn't start Spring
The single most useful thing to understand about unit testing in Spring Boot is that a real unit test
doesn't involve Spring at all. No @SpringBootTest, no ApplicationContext, no database. You take the
class under test, new it with mocked collaborators, and assert on the result:
class PriceServiceTest {
@Test
void appliesTenPercentDiscount() {
var service = new PriceService();
assertThat(service.discount(100)).isEqualTo(90);
}
}
That test runs in milliseconds. The moment you add @SpringBootTest, you're scanning components, wiring
beans, and booting auto-configuration — seconds per class. A suite of hundreds of those turns CI into a
coffee break. The test pyramid exists for a reason: make the fast, context-free unit tests the bulk of
your suite, and keep the slow context-loading tests few.
JUnit 5 is the baseline
Everything sits on JUnit 5 (Jupiter), which ships in spring-boot-starter-test. The annotations you'll use
constantly:
class CartTest {
@BeforeEach void setUp() { /* before every test */ }
@Test
@DisplayName("empty cart totals zero")
void emptyCartIsZero() { /* one case */ }
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void acceptsPositiveQuantities(int qty) { /* runs once per value */ }
}
Note the JUnit 4 → 5 shift: @BeforeEach (not @Before), classes and methods are package-private by
convention, and imports come from org.junit.jupiter.api. When several tests differ only by input, reach
for @ParameterizedTest instead of copy-pasting.
Mockito stands in for collaborators
The class under test usually depends on other things — a repository, an email sender. In a unit test you replace those with Mockito mocks, stub the calls your test exercises, and ignore the rest:
OrderRepository repo = mock(OrderRepository.class);
when(repo.findById(1L)).thenReturn(Optional.of(new Order(1L, "PAID")));
var service = new OrderService(repo);
assertThat(service.status(1L)).isEqualTo("PAID");
A mock returns sensible defaults (null, empty Optional, 0) for anything you don't stub, so you only
set up the calls on the path you're testing. The annotation-driven form removes the boilerplate:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repo;
@InjectMocks OrderService service; // mocks injected via constructor
}
Crucially, MockitoExtension is a JUnit extension, not the Spring context. This is still a pure,
fast unit test.
@Mock vs @MockBean: a common interview trap
These look interchangeable and absolutely are not. @Mock is plain Mockito — no Spring involved.
@MockBean (Spring Boot) creates a mock and registers it in the application context, replacing the real
bean, which means it only works in tests that load the context:
@ExtendWith(MockitoExtension.class) // fast unit test
class A { @Mock Repo repo; }
@SpringBootTest // slow — boots the context
class B { @MockBean Repo repo; } // swaps the real bean
Using @MockBean forces a context to start and can even trigger context restarts when the set of mocks
changes, hurting cache reuse. So: @Mock for true unit tests, @MockBean only inside @SpringBootTest/slice
tests. (On Boot 3.4+, @MockitoBean is the successor to @MockBean.)
Assert on results; verify only invisible effects
Prefer asserting on return values with AssertJ — its fluent API reads like a sentence and gives rich failure messages:
assertThat(items).hasSize(3)
.extracting(Item::name)
.containsExactly("a", "b", "c");
When the behavior is a side effect with no return value — an email sent, a row saved — use verify:
service.placeOrder(order);
verify(repo).save(order);
verify(emailService, times(1)).send(any());
verify(auditLog, never()).record(any());
Don't over-verify. Asserting every interaction makes tests brittle and couples them to implementation details. Verify the effect that's invisible from the return value, and nothing more. When you need to inspect an object built inside the method, capture it:
@Captor ArgumentCaptor<Order> captor;
service.placeOrder("widget", 3);
verify(repo).save(captor.capture());
assertThat(captor.getValue().getQuantity()).isEqualTo(3);
Testing the unhappy path
Exceptions are behavior too. Assert them explicitly with AssertJ's assertThatThrownBy so the test fails if
no exception is thrown, and confirm you caught the right one by checking the message:
assertThatThrownBy(() -> service.withdraw(-5))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be positive");
A bare assertThrows(Exception.class, ...) can mask an accidental NullPointerException — always pin the
type and message.
Design for testability: constructor injection
Notice that every example above just calls new OrderService(mock). That's only possible because the class
uses constructor injection. Field injection makes the same class painful to test:
// Testable — pass mocks straight in:
var service = new OrderService(mockRepo, mockEmailer);
// Field injection — no way to supply a mock without reflection:
// class OrderService { @Autowired OrderRepository repo; }
"Easy to unit test" is a direct consequence of "dependencies are explicit constructor parameters." This is the same reason Spring's own docs recommend constructor injection.
Control time and randomness
Code that calls Instant.now() or Math.random() directly is untestable — the result changes every run.
Make the nondeterminism a dependency you can pin:
class TokenService {
private final Clock clock;
TokenService(Clock clock) { this.clock = clock; }
Instant expiry() { return clock.instant().plusSeconds(60); }
}
var fixed = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
assertThat(new TokenService(fixed).expiry())
.isEqualTo(Instant.parse("2026-01-01T00:01:00Z"));
Inject a Clock (or a seeded random source) and time becomes a deterministic input.
One last guardrail: strict stubbing
MockitoExtension runs in strict-stubs mode, so a stub the test never uses fails with
UnnecessaryStubbingException. That's a feature — it catches copy-paste setup and dead code. The fix is
almost always to delete the unused stub, not to silence it with lenient().
Recap
Real Spring Boot unit tests don't start Spring: new the class, inject Mockito mocks, and assert in
milliseconds. Use JUnit 5 with @ParameterizedTest for varied inputs; know that @Mock is plain Mockito
while @MockBean drags in the context; assert results with AssertJ and verify only invisible side effects;
test exceptions with assertThatThrownBy; lean on constructor injection for testability; and inject a
Clock to tame time. Get these habits right and the fast base of your test pyramid will carry the suite.