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.
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
| Level | Spring context? | Speed | What it verifies |
|---|---|---|---|
| Unit | No | Milliseconds | A single class's logic in isolation |
| Slice | Partial (one layer) | Fast (hundreds of ms) | Framework integration for one concern (web binding, JPA queries) |
| Full integration / E2E | Full | Slow (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
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
| Annotation | Purpose |
|---|---|
@Test | Marks a test method |
@BeforeEach / @AfterEach | Runs before/after every test method in the class |
@BeforeAll / @AfterAll | Runs once for the whole class (must be static unless using @TestInstance(PER_CLASS)) |
@ParameterizedTest + @CsvSource/@ValueSource/@MethodSource | Run the same test logic across many inputs |
@Nested | Group related tests, share setup, improve readability of test output |
@DisplayName | Human-readable test name in reports |
@Disabled | Skip a test (always include a reason) |
@Tag | Categorize 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
@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
| Method | Purpose |
|---|---|
@Mock | Creates a mock instance of the annotated type |
@InjectMocks | Constructs 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 |
// 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
| Type | Behavior | Mockito API |
|---|---|---|
| Stub | Returns canned answers, no behavior verification | @Mock + given(...).willReturn(...), never verify()d |
| Mock | Canned answers + verifies interactions happened | @Mock + verify(mock).method(...) |
| Spy | Wraps a real object; real methods run unless explicitly stubbed | @Spy or Mockito.spy(realObject) |
| Fake | A working, simplified implementation (e.g., in-memory repository) | Hand-written class implementing the same interface |
@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
@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
@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
@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
| Slice | Loads | Use for |
|---|---|---|
@WebMvcTest | Controllers, HandlerMapping, message converters, @ControllerAdvice | Controller request/response mapping, validation, status codes |
@DataJpaTest | JPA repositories, EntityManager, embedded DB | Query correctness, entity mapping |
@JsonTest | Jackson ObjectMapper and related config | JSON serialization/deserialization edge cases |
@RestClientTest | RestClient/RestTemplate beans + MockRestServiceServer | Outbound HTTP client behavior against mocked responses |
@DataMongoTest, @DataRedisTest | Equivalent slices for other data stores | Repository-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.
@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.
# 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
@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
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
@WebMvcTestand@DataJpaTest(and other slices) to test framework integration for one layer without paying the cost of a full application context. - Reserve
@SpringBootTestfor 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 withgiven().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
@Transactionalmethods 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
@WebMvcTestload, and why does a controller test typically need@MockBeanfor its service dependency? - What does
@DataJpaTestdo 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()versusverify(mock, never()).method()? - What problem does
ArgumentCaptorsolve thatargThat()alone sometimes can't express cleanly? - Why might Testcontainers-based tests catch bugs that H2-based
@DataJpaTesttests miss? - What does
@ServiceConnectiondo in a Testcontainers-based Spring Boot test, and what boilerplate did it replace? - Why is
@SpringBootTestconsidered 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
@Transactionalservice method? - What risk comes from over-using
@Spyon your own domain classes rather than on third-party code? - How do parameterized tests (
@ParameterizedTestwith@CsvSource) reduce duplication compared to writing a separate@Testmethod per case?