Skip to content

Spring Boot · Testing

Unit Testing Spring Boot: Fast Tests Without the Context

6 min read Updated 2026-06-26 Share:

Practice Unit Testing interview questions

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.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel