Skip to content

Unit Testing Interview Questions & Answers

15 questions Updated 2026-06-26 Share:

Unit testing Spring Boot code — plain JUnit 5 tests without the Spring context, mocking collaborators with Mockito, @Mock vs @MockBean, constructor injection for testability, AssertJ assertions, verifying interactions, and testing exceptions.

Read the in-depth guideUnit Testing Spring Boot: Fast Tests Without the Context(opens in new tab)
15 of 15

A unit test exercises a single class (a service, a helper, a mapper) in isolation, with its collaborators replaced by mocks. It should not start the Spring ApplicationContext, hit a database, or touch the network — that makes it a slow integration test, not a unit test.

// Plain JUnit 5 — no @SpringBootTest, no Spring at all
class PriceServiceTest {
    @Test
    void appliesDiscount() {
        var service = new PriceService();           // just `new` it
        assertThat(service.discount(100)).isEqualTo(90);
    }
}

Because nothing Spring-related loads, these tests run in milliseconds. The whole point is speed and focus: one class, deterministic inputs, no infrastructure.

Rule of thumb: If a test needs @SpringBootTest to pass, it isn't a unit test — keep unit tests free of the Spring context.

JUnit 5 (Jupiter) is the default test engine in Spring Boot. The essentials:

class CartTest {
    @BeforeEach void setUp() { /* runs before every test */ }
    @AfterEach  void tearDown() { /* runs after every test */ }

    @Test
    @DisplayName("empty cart totals zero")
    void emptyCartIsZero() { /* a single test case */ }

    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3})
    void acceptsPositiveQuantities(int qty) { /* runs once per value */ }

    @Disabled("flaky — see TICKET-123")
    @Test void skipped() { }
}

Key shift from JUnit 4: it's @BeforeEach/@AfterEach (not @Before/@After), test classes and methods are package-private by convention, and you import from org.junit.jupiter.api.

Rule of thumb: Reach for @ParameterizedTest instead of copy-pasting near-identical @Test methods that differ only by input.

Starting the context with @SpringBootTest scans components, wires beans, opens datasources, and runs auto-configuration — seconds of startup per test class. A pure unit test that just news the class under test runs in single-digit milliseconds.

// SLOW: boots the whole app to test one method
@SpringBootTest
class SlowServiceTest { @Autowired OrderService service; /* ... */ }

// FAST: no Spring, mocks injected by constructor
class FastServiceTest {
    OrderService service = new OrderService(mock(OrderRepository.class));
}

A suite of hundreds of context-booting tests turns a CI run into minutes. Keep the slow, context-loading tests few and deliberate (integration tests), and make the fast unit tests the bulk of your suite — the classic test pyramid.

Rule of thumb: Push logic into plain classes you can new and test without Spring; reserve the context for genuine integration tests.

Mockito (bundled with spring-boot-starter-test) creates stand-in objects whose methods you stub with when(...).thenReturn(...). You inject the mock into the class under test and assert on the result.

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 for un-stubbed methods (null, empty Optional, 0, false), so you only stub the calls your test actually exercises. This lets you test the service's logic independent of the real repository, DB, or network.

Rule of thumb: Mock the collaborators, not the class under test — stub only what the test path touches.

They remove the boilerplate of creating mocks and wiring them by hand. The MockitoExtension initializes any @Mock fields and injects them into the @InjectMocks target.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock OrderRepository repo;          // a Mockito mock
    @InjectMocks OrderService service;   // mocks injected into it

    @Test void findsOrder() {
        when(repo.findById(1L)).thenReturn(Optional.of(new Order(1L, "PAID")));
        assertThat(service.status(1L)).isEqualTo("PAID");
    }
}

@InjectMocks prefers constructor injection to wire the mocks. This is still a pure unit test — MockitoExtension is a JUnit extension, not the Spring context.

Rule of thumb: Use @Mock/@InjectMocks for readability, but remember it's plain Mockito — no Spring beans are involved.

They look similar but live in different worlds. @Mock (Mockito) creates a bare mock object with no Spring context involved. @MockBean (Spring Boot) creates a mock and registers it in the ApplicationContext, replacing the real bean — so 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 for a mock

Using @MockBean forces a context to load (and can trigger a context restart when the set of mocks changes, hurting cache reuse). Reach for it only inside @SpringBootTest/slice tests; for true unit tests use plain @Mock.

Rule of thumb: @Mock = no Spring, fast; @MockBean = swaps a context bean, only in context-loading tests. (Boot 3.4+ also offers @MockitoBean as the successor to @MockBean.)

verify() asserts an interaction happened — useful when the behavior under test is a side effect (an email sent, a row saved) rather than a return value.

service.placeOrder(order);

verify(repo).save(order);                 // called exactly once (default)
verify(emailService, times(1)).send(any());
verify(auditLog, never()).record(any());  // must NOT have been called
verifyNoMoreInteractions(repo);           // nothing else touched repo

You can match arguments exactly or with matchers (any(), eq(), argThat(...)), and assert counts with times(n), never(), atLeastOnce(). Use an ArgumentCaptor when you need to inspect the object that was passed.

Rule of thumb: Assert on return values when you can and verify interactions only when the effect is invisible from the return — over-verifying makes tests brittle.

An ArgumentCaptor grabs the actual argument passed to a mock so you can assert on its internals — handy when the object is built inside the method under test and never returned.

@Captor ArgumentCaptor<Order> captor;

service.placeOrder("widget", 3);

verify(repo).save(captor.capture());
Order saved = captor.getValue();
assertThat(saved.getQuantity()).isEqualTo(3);
assertThat(saved.getStatus()).isEqualTo("NEW");

Without a captor you'd have to construct an equals-matching expected object, which is awkward when the method sets timestamps or generated IDs. The captor lets you assert on just the fields you care about.

Rule of thumb: Use an ArgumentCaptor to inspect objects created inside the method; use eq(...) matchers when you already hold the exact expected value.

AssertJ (also bundled in the starter) gives fluent, chainable, readable assertions with far better failure messages than JUnit's assertEquals. You start every assertion with assertThat.

assertThat(order.getStatus()).isEqualTo("PAID");
assertThat(items).hasSize(3)
                 .extracting(Item::name)
                 .containsExactly("a", "b", "c");
assertThat(price).isPositive().isLessThan(100);

The fluent API reads like a sentence and autocompletes in the IDE, guiding you to the right assertion for the type. On failure it prints both expected and actual richly, instead of JUnit's terse message.

Rule of thumb: Standardize on assertThat(...) from AssertJ across the codebase for consistent, self-documenting assertions.

Use AssertJ's assertThatThrownBy (or JUnit's assertThrows) to capture the thrown exception and assert on its type and message — never wrap code in a try/catch with a manual fail().

assertThatThrownBy(() -> service.withdraw(-5))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("must be positive");

// JUnit equivalent:
var ex = assertThrows(IllegalArgumentException.class,
                      () -> service.withdraw(-5));
assertThat(ex.getMessage()).contains("must be positive");

This makes the expected failure explicit and fails the test if no exception is thrown. Asserting on the message (or a typed field) ensures you caught the right exception, not an accidental NPE.

Rule of thumb: Assert the exception's type and message; a bare assertThrows(Exception.class,...) can mask the wrong failure.

With constructor injection every dependency is a parameter, so a test can pass mocks directly — no Spring, no reflection, no field hacking. Field injection (@Autowired on a private field) leaves no way to supply a mock without the container or reflection tricks.

// Testable: just call the constructor with mocks
var service = new OrderService(mockRepo, mockEmailer);

// Field injection makes this impossible without reflection:
// class OrderService { @Autowired OrderRepository repo; }

The constructor also documents exactly what the class needs and lets you mark fields final. This is the main practical reason Spring's own docs recommend constructor injection.

Rule of thumb: Prefer constructor injection — "easy to unit test" is a direct consequence of "dependencies are explicit constructor parameters."

when(...).thenReturn(...) can't be used on a void method, so Mockito provides the doThrow/doNothing/doAnswer family that you call before the method.

// Make a void method throw:
doThrow(new MailException("down")).when(emailService).send(any());

// Make a returning method throw:
when(repo.findById(1L)).thenThrow(new DataAccessException("db") {});

// Custom dynamic answer:
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));

doThrow/doNothing exist precisely because the when(obj.voidMethod()) form won't compile for void returns. thenAnswer is for when the stubbed result depends on the arguments (e.g. echoing back the saved entity).

Rule of thumb: Use when().thenReturn() for normal returns, the do*() family for void methods and throwing, and thenAnswer when the result depends on the input.

No — test private methods through the public API that calls them. Private methods are implementation detail; if they're well covered by tests of the public behavior, you're done. Reaching in with reflection couples tests to internals and breaks on refactor.

// Don't: reflect into private calculateTax()
// Do: test the public method whose result depends on it
assertThat(invoiceService.total(order)).isEqualTo(108);  // exercises tax logic

If a private method is so complex it feels like it needs its own test, that's a design smell — it probably wants to be extracted into its own collaborator class with a public method, which is then naturally testable.

Rule of thumb: Test behavior through public methods; if a private method begs for direct testing, extract it into its own class instead.

MockitoExtension runs in strict stubs mode by default. If you stub a call the test never uses, Mockito fails with UnnecessaryStubbingException to catch dead setup and copy-paste mistakes.

@Test void onlyUsesOne() {
    when(repo.findById(1L)).thenReturn(Optional.of(order)); // used ✓
    when(repo.count()).thenReturn(5L);                      // never used ✗ → error
    assertThat(service.status(1L)).isEqualTo("PAID");
}

The fix is almost always to delete the unused stub. If a stub is genuinely shared by only some tests in a class, use lenient().when(...) for that one stub, or set @MockitoSettings(strictness = Strictness.LENIENT) — but treat that as a last resort.

Rule of thumb: Let strict stubbing keep your tests honest — delete unused stubs rather than silencing the check with lenient().

Make the nondeterminism a dependency you can control. Inject a java.time.Clock instead of calling Instant.now(), and inject a supplier/Random with a fixed seed instead of Math.random().

class TokenService {
    private final Clock clock;
    TokenService(Clock clock) { this.clock = clock; }
    Instant expiry() { return clock.instant().plusSeconds(60); }
}

// Test with a fixed clock:
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"));

Hard-coded now()/random() calls are untestable because the result changes every run. Injecting a Clock turns time into a deterministic input you can pin in the test.

Rule of thumb: Never call Instant.now()/Math.random() directly in logic you want to test — inject a Clock or seeded source and control it from the test.

More ways to practice

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

Join our WhatsApp Channel