03-spring-boot-essentials

Testing Spring Boot Apps: JUnit 5, Mockito, and Test Slices

A staff-engineer guide to JUnit 5, Mockito mocking, Spring Boot test slices, and Testcontainers for reliable, fast Spring Boot test suites.

August 14, 2026
backend-engineerjunitmockitotestingspring-boot-testtestcontainers

Testing Spring Boot Apps: JUnit 5, Mockito, and Test Slices

A test suite that takes ten minutes and still misses production bugs is worse than no test suite — it slows the team down while providing false confidence. This guide covers the actual toolkit Spring Boot teams use to build fast, reliable tests: JUnit 5 fundamentals, Mockito for isolating collaborators, Spring's test slices for testing framework integration cheaply, and Testcontainers for integration tests that reflect production infrastructure without sacrificing repeatability.


1. The Testing Pyramid, Applied to Spring Boot

LevelSpring context?SpeedWhat it verifies
UnitNoMillisecondsA single class's logic in isolation
SlicePartial (one layer)Fast (hundreds of ms)Framework integration for one concern (web binding, JPA queries)
Full integration / E2EFullSlow (seconds)The whole wiring works together, including real infra via Testcontainers
💡

Most of your tests should be unit tests that never touch Spring at all. A service class with its dependencies mocked via Mockito doesn't need @SpringBootTest — starting the full application context for every test class is the single biggest cause of slow Spring Boot test suites.


2. JUnit 5 Fundamentals

java
class OrderCalculatorTest {
 
    private OrderCalculator calculator;
 
    @BeforeEach
    void setUp() {
        calculator = new OrderCalculator();
    }
 
    @Test
    void calculatesTotalWithoutDiscount() {
        Order order = new Order(List.of(new LineItem("sku-1", 2, new BigDecimal("10.00"))));
 
        BigDecimal total = calculator.total(order);
 
        assertThat(total).isEqualByComparingTo("20.00");
    }
 
    @Test
    void throwsWhenOrderHasNoItems() {
        Order emptyOrder = new Order(List.of());
 
        assertThatThrownBy(() -> calculator.total(emptyOrder))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("at least one line item");
    }
 
    @ParameterizedTest
    @CsvSource({
        "1, 10.00, 10.00",
        "3, 10.00, 30.00",
        "5, 19.99, 99.95"
    })
    void calculatesTotalForVariousQuantities(int quantity, String unitPrice, String expectedTotal) {
        Order order = new Order(List.of(new LineItem("sku-1", quantity, new BigDecimal(unitPrice))));
 
        assertThat(calculator.total(order)).isEqualByComparingTo(expectedTotal);
    }
 
    @Nested
    class WhenApplyingDiscounts {
 
        @Test
        void appliesPercentageDiscountCorrectly() {
            Order order = new Order(List.of(new LineItem("sku-1", 1, new BigDecimal("100.00"))));
 
            BigDecimal total = calculator.totalWithDiscount(order, new BigDecimal("0.10"));
 
            assertThat(total).isEqualByComparingTo("90.00");
        }
    }
}

Key JUnit 5 annotations

AnnotationPurpose
@TestMarks a test method
@BeforeEach / @AfterEachRuns before/after every test method in the class
@BeforeAll / @AfterAllRuns once for the whole class (must be static unless using @TestInstance(PER_CLASS))
@ParameterizedTest + @CsvSource/@ValueSource/@MethodSourceRun the same test logic across many inputs
@NestedGroup related tests, share setup, improve readability of test output
@DisplayNameHuman-readable test name in reports
@DisabledSkip a test (always include a reason)
@TagCategorize tests (e.g., @Tag("slow")) for selective execution

Use AssertJ's fluent assertions (assertThat(...)) instead of JUnit's built-in assertEquals/assertTrue. AssertJ reads closer to natural language, chains multiple assertions fluently, and produces far more readable failure messages — especially for collections (.containsExactly(...), .hasSize(...)) and exceptions (.isInstanceOf(...).hasMessageContaining(...)).


3. Mockito: Isolating Collaborators

java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
 
    @Mock
    private OrderRepository orderRepository;
 
    @Mock
    private PaymentGateway paymentGateway;
 
    @Mock
    private EventPublisher eventPublisher;
 
    @InjectMocks
    private OrderService orderService; // Mockito injects the @Mock fields via constructor
 
    @Test
    void placeOrder_chargesPaymentAndPublishesEvent() {
        OrderCommand command = new OrderCommand("cust-1", List.of(new LineItemCommand("sku-1", 2)));
        Order savedOrder = new Order("order-1", "cust-1", OrderStatus.PENDING);
        given(orderRepository.save(any(Order.class))).willReturn(savedOrder);
        given(paymentGateway.charge(any())).willReturn(PaymentResult.success("txn-123"));
 
        Order result = orderService.placeOrder(command);
 
        assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
        verify(paymentGateway).charge(argThat(req -> req.orderId().equals("order-1")));
        verify(eventPublisher).publish(any(OrderPlacedEvent.class));
    }
 
    @Test
    void placeOrder_doesNotChargeWhenValidationFails() {
        OrderCommand invalidCommand = new OrderCommand("cust-1", List.of());
 
        assertThatThrownBy(() -> orderService.placeOrder(invalidCommand))
            .isInstanceOf(IllegalArgumentException.class);
 
        verifyNoInteractions(paymentGateway); // guarantees no charge happened on invalid input
    }
 
    @Test
    void placeOrder_rollsBackWhenPaymentFails() {
        OrderCommand command = new OrderCommand("cust-1", List.of(new LineItemCommand("sku-1", 1)));
        given(orderRepository.save(any())).willReturn(new Order("order-1", "cust-1", OrderStatus.PENDING));
        given(paymentGateway.charge(any())).willThrow(new PaymentDeclinedException("insufficient funds"));
 
        assertThatThrownBy(() -> orderService.placeOrder(command))
            .isInstanceOf(PaymentDeclinedException.class);
 
        verify(orderRepository).markFailed("order-1");
    }
}

Mockito API reference

MethodPurpose
@MockCreates a mock instance of the annotated type
@InjectMocksConstructs the target object, injecting @Mock fields via constructor (preferred), setter, or field
given(...).willReturn(...) (BDD style) / when(...).thenReturn(...)Stub a method call's return value
given(...).willThrow(...)Stub a method to throw
verify(mock).method(...)Assert a method was called with specific arguments
verify(mock, times(n))Assert a call count
verify(mock, never())Assert a method was never called
verifyNoInteractions(mock)Assert nothing was called on this mock at all
argThat(predicate) / ArgumentCaptor<T>Assert on the shape of arguments passed to a mock
java
// ArgumentCaptor — when you need to inspect the exact object passed to a mock
@Test
void placeOrder_publishesEventWithCorrectPayload() {
    ArgumentCaptor<OrderPlacedEvent> captor = ArgumentCaptor.forClass(OrderPlacedEvent.class);
    given(orderRepository.save(any())).willReturn(new Order("order-1", "cust-1", OrderStatus.PENDING));
    given(paymentGateway.charge(any())).willReturn(PaymentResult.success("txn-1"));
 
    orderService.placeOrder(new OrderCommand("cust-1", List.of(new LineItemCommand("sku-1", 1))));
 
    verify(eventPublisher).publish(captor.capture());
    assertThat(captor.getValue().orderId()).isEqualTo("order-1");
}

Prefer BDD-style given(...).willReturn(...) over when(...).thenReturn(...) — both work identically, but the BDD style reads as Given/When/Then and keeps stubbing (arrange) visually distinct from assertions (verify) later in the test body.

⚠️

@InjectMocks uses reflection and silently does nothing if it can't find a matching constructor or field. It works well for simple cases but can mask wiring mistakes. For services with several dependencies, some engineers prefer to skip @InjectMocks entirely and construct the object explicitly (new OrderService(orderRepository, paymentGateway, eventPublisher)) — it's more verbose but fails loudly (a compile error) instead of silently if a dependency doesn't get injected.


4. Test Doubles: Mock vs Stub vs Spy

TypeBehaviorMockito API
StubReturns canned answers, no behavior verification@Mock + given(...).willReturn(...), never verify()d
MockCanned answers + verifies interactions happened@Mock + verify(mock).method(...)
SpyWraps a real object; real methods run unless explicitly stubbed@Spy or Mockito.spy(realObject)
FakeA working, simplified implementation (e.g., in-memory repository)Hand-written class implementing the same interface
java
@Test
void spyExample_realMethodRunsUnlessStubbed() {
    List<String> realList = new ArrayList<>();
    List<String> spyList = spy(realList);
 
    spyList.add("a");           // real ArrayList.add() executes
    doReturn(100).when(spyList).size(); // override just this one method
 
    assertThat(spyList).contains("a"); // real behavior preserved
    assertThat(spyList.size()).isEqualTo(100); // stubbed behavior
}
🚨

Avoid spying on your own domain/service classes — if you need to stub some methods and use real behavior for others on the same class you're testing, that's usually a sign the class has too many responsibilities and should be split. Spies are most legitimately used to wrap third-party or legacy classes you can't easily refactor.


5. @SpringBootTest: Full Application Context

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrderApiIntegrationTest {
 
    @Autowired
    private TestRestTemplate restTemplate;
 
    @MockBean
    private PaymentGateway paymentGateway; // real Spring context, but this bean is mocked
 
    @Test
    void createOrder_persistsAndReturns201() {
        given(paymentGateway.charge(any())).willReturn(PaymentResult.success("txn-1"));
 
        CreateOrderRequest request = new CreateOrderRequest("cust-1",
            List.of(new LineItemRequest("sku-1", 2, new BigDecimal("10.00"))));
 
        ResponseEntity<OrderResponse> response =
            restTemplate.postForEntity("/api/v1/orders", request, OrderResponse.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().status()).isEqualTo("CONFIRMED");
    }
}
⚠️

@SpringBootTest starts the entire application context — every bean, every auto-configuration, potentially a real embedded database. It is the slowest and most expensive test type. Reserve it for genuine end-to-end scenarios (a handful per feature), and use test slices for everything else. A test suite with hundreds of @SpringBootTest classes routinely takes 20+ minutes; the same coverage with slice tests can run in under a minute.


6. Test Slices: Testing One Layer at a Time

Spring Boot's test slices load only the beans relevant to one architectural layer, dramatically reducing startup time and scope.

@WebMvcTest — controller layer only

java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @MockBean
    private OrderService orderService; // service layer mocked — no real business logic runs
 
    @Test
    void getOrder_returns200WithOrderBody() throws Exception {
        given(orderService.findById("order-1")).willReturn(sampleOrder());
 
        mockMvc.perform(get("/api/v1/orders/order-1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("order-1"))
            .andExpect(jsonPath("$.status").value("CONFIRMED"));
    }
 
    @Test
    void createOrder_returns400WhenValidationFails() throws Exception {
        CreateOrderRequest invalidRequest = new CreateOrderRequest("", List.of()); // fails @NotBlank, @NotEmpty
 
        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray());
    }
 
    @Test
    void getOrder_returns404WhenNotFound() throws Exception {
        given(orderService.findById("missing")).willThrow(new OrderNotFoundException("missing"));
 
        mockMvc.perform(get("/api/v1/orders/missing"))
            .andExpect(status().isNotFound());
    }
}
💡

@WebMvcTest auto-configures MockMvc, Jackson message converters, and (by default) your @RestControllerAdvice exception handlers — but not @Service, @Repository, or @Component beans outside the web layer. That's exactly why OrderService must be @MockBean; without it, the context fails to start because OrderController's constructor dependency can't be satisfied.

@DataJpaTest — repository layer only

java
@DataJpaTest
class OrderRepositoryTest {
 
    @Autowired
    private TestEntityManager entityManager;
 
    @Autowired
    private OrderRepository orderRepository;
 
    @Test
    void findByCustomerId_returnsOnlyMatchingOrders() {
        entityManager.persist(new OrderEntity("order-1", "cust-1", OrderStatus.CONFIRMED));
        entityManager.persist(new OrderEntity("order-2", "cust-2", OrderStatus.CONFIRMED));
        entityManager.flush();
 
        List<OrderEntity> results = orderRepository.findByCustomerId("cust-1");
 
        assertThat(results).hasSize(1).extracting(OrderEntity::getId).containsExactly("order-1");
    }
}

@DataJpaTest runs each test in a transaction that's rolled back afterward by default — tests never leak data into each other, and you don't need manual cleanup. It also swaps in an embedded database (H2 by default) unless you explicitly disable that via @AutoConfigureTestDatabase(replace = Replace.NONE), which you'll want to do once you move to Testcontainers (see below) for a database that matches production.

Common Spring Boot test slices

SliceLoadsUse for
@WebMvcTestControllers, HandlerMapping, message converters, @ControllerAdviceController request/response mapping, validation, status codes
@DataJpaTestJPA repositories, EntityManager, embedded DBQuery correctness, entity mapping
@JsonTestJackson ObjectMapper and related configJSON serialization/deserialization edge cases
@RestClientTestRestClient/RestTemplate beans + MockRestServiceServerOutbound HTTP client behavior against mocked responses
@DataMongoTest, @DataRedisTestEquivalent slices for other data storesRepository-layer tests for NoSQL stores

7. Testcontainers: Integration Tests Against Real Infrastructure

Embedded databases like H2 are fast but don't behave identically to production PostgreSQL — different SQL dialects, different constraint enforcement, different JSON column support. Testcontainers spins up real Dockerized infrastructure for the duration of a test run.

java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryIntegrationTest {
 
    @Container
    @ServiceConnection // Spring Boot 3.1+ auto-wires datasource properties from the container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
 
    @Autowired
    private OrderRepository orderRepository;
 
    @Test
    void savesAndRetrievesOrderWithJsonbColumn() {
        OrderEntity order = new OrderEntity("order-1", "cust-1", OrderStatus.PENDING);
        orderRepository.save(order);
 
        Optional<OrderEntity> found = orderRepository.findById("order-1");
 
        assertThat(found).isPresent();
    }
}
💡

@ServiceConnection (Spring Boot 3.1+) eliminates the manual @DynamicPropertySource boilerplate that older Testcontainers integrations required to wire the container's dynamically-assigned port into spring.datasource.url. It automatically detects the container type (Postgres, Kafka, Redis, etc.) and configures the matching Spring Boot auto-configuration.

yaml
# build.gradle / pom.xml dependency (illustrative)
# testImplementation 'org.springframework.boot:spring-boot-testcontainers'
# testImplementation 'org.testcontainers:postgresql'
# testImplementation 'org.testcontainers:junit-jupiter'
⚠️

Testcontainers tests require a running Docker daemon in CI, which adds setup cost and run time compared to H2. Use them for the subset of tests where database-specific behavior genuinely matters (native jsonb queries, specific constraint behaviors, migration scripts) — not as a blanket replacement for every repository test.


8. Testing @Transactional Behavior

java
@SpringBootTest
class OrderTransactionTest {
 
    @Autowired
    private OrderService orderService;
 
    @MockBean
    private PaymentGateway paymentGateway;
 
    @Autowired
    private OrderRepository orderRepository;
 
    @Test
    void failedPaymentRollsBackOrderCreation() {
        given(paymentGateway.charge(any())).willThrow(new PaymentDeclinedException("declined"));
 
        assertThatThrownBy(() -> orderService.placeOrder(sampleCommand()))
            .isInstanceOf(PaymentDeclinedException.class);
 
        // Because @Transactional wraps placeOrder(), the save should have rolled back too
        assertThat(orderRepository.findById("order-1")).isEmpty();
    }
}
⚠️

Tests annotated @Transactional at the test class level roll back automatically after each test — convenient for cleanup, but it can mask a real bug where your production @Transactional boundary doesn't actually roll back correctly (e.g., because of the checked-exception pitfall covered in the validation and exception-handling guide). Verify rollback behavior explicitly, as in the example above, rather than relying on the test's own transactional cleanup to hide the question.


9. Test Naming and Structure Conventions

java
class OrderServiceTest {
 
    // Convention: methodUnderTest_condition_expectedOutcome
    @Test
    void placeOrder_withValidCommand_returnsConfirmedOrder() { }
 
    @Test
    void placeOrder_withEmptyItems_throwsIllegalArgumentException() { }
 
    @Test
    void placeOrder_whenPaymentDeclined_marksOrderFailed() { }
}

Structure every test body around Arrange / Act / Assert (or Given/When/Then), even without comments marking the sections — it keeps tests readable and makes it obvious when a test is doing too much (multiple unrelated "Act" steps is a sign it should be split into separate tests).


Key takeaways

  • Most tests should be plain JUnit + Mockito unit tests with no Spring context at all — that's what keeps a suite fast as it grows.
  • Use @WebMvcTest and @DataJpaTest (and other slices) to test framework integration for one layer without paying the cost of a full application context.
  • Reserve @SpringBootTest for genuine end-to-end scenarios; it starts the entire context and is the slowest test type by a wide margin.
  • @Mock + verify() checks interactions happened; a plain stub with given().willReturn() alone just supplies canned data — know which one your test actually needs.
  • verifyNoInteractions() is a strong, explicit assertion that a collaborator was never touched — use it to prove side effects didn't happen on invalid input.
  • Testcontainers gives integration tests real infrastructure (real Postgres, real Kafka) instead of an approximate in-memory substitute — reserve it for tests where that fidelity actually matters.
  • @ServiceConnection (Spring Boot 3.1+) removes the manual property-wiring boilerplate that older Testcontainers setups required.
  • Test rollback behavior for @Transactional methods explicitly — don't rely on the test framework's own transactional cleanup to paper over a broken rollback boundary in production code.

Interview Questions

  • What is the difference between a unit test, a slice test, and a full @SpringBootTest, and when would you use each?
  • Why should the majority of a Spring Boot test suite avoid starting the Spring context at all?
  • What does @WebMvcTest load, and why does a controller test typically need @MockBean for its service dependency?
  • What does @DataJpaTest do differently from a plain repository unit test, and why does it matter for catching SQL-dialect-specific bugs?
  • Explain the difference between a mock, a stub, and a spy in Mockito terms.
  • When would you use verifyNoInteractions() versus verify(mock, never()).method()?
  • What problem does ArgumentCaptor solve that argThat() alone sometimes can't express cleanly?
  • Why might Testcontainers-based tests catch bugs that H2-based @DataJpaTest tests miss?
  • What does @ServiceConnection do in a Testcontainers-based Spring Boot test, and what boilerplate did it replace?
  • Why is @SpringBootTest considered expensive, and what's the practical impact on CI time as a suite grows?
  • How would you test that a failed payment correctly rolls back an order creation inside a @Transactional service method?
  • What risk comes from over-using @Spy on your own domain classes rather than on third-party code?
  • How do parameterized tests (@ParameterizedTest with @CsvSource) reduce duplication compared to writing a separate @Test method per case?