Spring Boot testing — @WebMvcTest for controllers, @DataJpaTest for repositories, @SpringBootTest only for integration, MockMvc, @MockBean vs @Mock, AssertJ, @Transactional rollback, @ActiveProfiles, TestContainers
93
89%
Does it follow best practices?
Impact
100%
1.09xAverage score across 5 eval scenarios
Passed
No known issues
Write tests that are fast, isolated, and catch real bugs. Use the narrowest test slice that covers the behavior.
Spring Boot provides slice annotations that load only the beans needed for a specific layer. Do not use @SpringBootTest when a slice annotation will do — full context startup is slow and hides coupling bugs.
| Annotation | What it loads | Use for |
|---|---|---|
@WebMvcTest(Controller.class) | Controller + MVC infrastructure only | Controller request mapping, validation, serialization |
@DataJpaTest | JPA repositories + embedded DB | Repository queries, entity mappings |
@SpringBootTest | Full application context | End-to-end integration, multi-layer flows |
@WebMvcTest@WebMvcTest loads only the web layer. You must mock service dependencies with @MockBean:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void createOrder_returnsCreated() throws Exception {
OrderDto order = new OrderDto(1L, "Test Customer", List.of());
when(orderService.createOrder(any())).thenReturn(order);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "Test Customer", "items": []}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.customerName").value("Test Customer"));
}
@Test
void createOrder_rejectsEmptyName() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "", "items": []}
"""))
.andExpect(status().isBadRequest());
}
@Test
void getOrder_notFound() throws Exception {
when(orderService.getOrder(99999L))
.thenThrow(new OrderNotFoundException(99999L));
mockMvc.perform(get("/api/orders/99999"))
.andExpect(status().isNotFound());
}
}Why @WebMvcTest over @SpringBootTest + @AutoConfigureMockMvc:
@MockBean@DataJpaTest@DataJpaTest configures an embedded database, scans for @Entity classes, and configures JPA repositories. Tests are @Transactional and auto-rollback after each test — no manual cleanup needed:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByCustomerName_returnsMatchingOrders() {
Order order = new Order();
order.setCustomerName("Alice");
order.setStatus(OrderStatus.PENDING);
entityManager.persistAndFlush(order);
List<Order> found = orderRepository.findByCustomerName("Alice");
assertThat(found).hasSize(1);
assertThat(found.get(0).getCustomerName()).isEqualTo("Alice");
}
@Test
void findByStatus_returnsEmpty_whenNoneMatch() {
List<Order> found = orderRepository.findByStatus(OrderStatus.COMPLETED);
assertThat(found).isEmpty();
}
}Key points:
TestEntityManager gives you persistAndFlush() for test data — avoids going through repository for setup@BeforeEach cleanup@SpringBootTest (only when needed)Use @SpringBootTest when you need to test flows that cross multiple layers (controller -> service -> repository):
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class OrderIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void createAndRetrieveOrder() throws Exception {
MvcResult result = mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "Integration Test", "items": [{"productId": 1, "quantity": 2}]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andReturn();
String id = JsonPath.read(
result.getResponse().getContentAsString(), "$.id").toString();
mockMvc.perform(get("/api/orders/" + id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.customerName").value("Integration Test"));
}
}Use @Transactional on integration test classes to auto-rollback database changes. This eliminates the need for @BeforeEach cleanup and @DirtiesContext.
@MockBean vs @Mock — Placement Matters| Annotation | Where | Effect |
|---|---|---|
@MockBean | On fields in @WebMvcTest / @SpringBootTest classes | Replaces the bean in the Spring context |
@Mock + @InjectMocks | On fields in plain unit tests (no Spring) | Mockito-only, no Spring context |
Rule: Use @Mock for unit tests, @MockBean for Spring slice tests.
// Plain unit test — fast, no Spring context
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
@Test
void processOrder_calculatesTotal() {
// test pure business logic
}
}Never use @MockBean in a plain unit test — it starts a Spring context unnecessarily.
| Tool | Use when | Style |
|---|---|---|
MockMvc | @WebMvcTest, @SpringBootTest with servlet stack | Synchronous, .andExpect() chain |
WebTestClient | @SpringBootTest(webEnvironment = RANDOM_PORT), WebFlux | Reactive/fluent, .expectStatus() chain |
// WebTestClient for running server tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiWebTest {
@Autowired
private WebTestClient webTestClient;
@Test
void getOrders_returnsOk() {
webTestClient.get().uri("/api/orders")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$").isArray();
}
}Prefer MockMvc for controller slice tests. Use WebTestClient when testing with a running server or WebFlux.
Always use AssertJ (assertThat) instead of JUnit's assertEquals/assertTrue. It is included with spring-boot-starter-test:
// BAD — JUnit assertions, poor failure messages
assertEquals(3, orders.size());
assertTrue(order.getStatus() == OrderStatus.COMPLETED);
// GOOD — AssertJ, readable and descriptive failures
assertThat(orders).hasSize(3);
assertThat(orders).extracting(Order::getStatus)
.containsOnly(OrderStatus.PENDING, OrderStatus.COMPLETED);
assertThat(order.getTotal()).isCloseTo(BigDecimal.valueOf(29.99), within(BigDecimal.valueOf(0.01)));
assertThat(order.getCustomerName()).isEqualTo("Alice");@SqlUse @Sql to load test data from SQL scripts instead of manual entity creation:
@DataJpaTest
@Sql("/test-data/orders.sql")
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void findPendingOrders_returnsExpectedCount() {
List<Order> pending = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(pending).hasSize(3);
}
}-- src/test/resources/test-data/orders.sql
INSERT INTO orders (id, customer_name, status, total) VALUES (1, 'Alice', 'PENDING', 25.00);
INSERT INTO orders (id, customer_name, status, total) VALUES (2, 'Bob', 'PENDING', 30.00);
INSERT INTO orders (id, customer_name, status, total) VALUES (3, 'Carol', 'PENDING', 15.00);
INSERT INTO orders (id, customer_name, status, total) VALUES (4, 'Dave', 'COMPLETED', 40.00);@Sql runs before each test method by default. Combined with @Transactional rollback, each test gets a clean dataset.
When you need to test against a real database (PostgreSQL, MySQL), use TestContainers:
@SpringBootTest
@Testcontainers
@Transactional
class OrderRepositoryPostgresTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void nativeQuery_worksWithRealPostgres() {
// Test Postgres-specific features (JSONB, full-text search, etc.)
}
}@ServiceConnection (Spring Boot 3.1+) auto-configures the datasource from the container — no manual property setup.
Add the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>@ActiveProfilesUse @ActiveProfiles("test") to load test-specific configuration:
@SpringBootTest
@ActiveProfiles("test")
class OrderIntegrationTest { ... }# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
logging:
level:
org.springframework.test: DEBUG@DirtiesContext@DirtiesContext destroys and recreates the Spring context after a test. This is extremely slow. Instead:
@Transactional on test classes for auto-rollback (preferred)@Sql with executionPhase = AFTER_TEST_METHOD for cleanup@BeforeEach to reset only what you needOnly use @DirtiesContext if a test modifies Spring beans themselves (e.g., changing configuration properties at runtime).
Spring Boot includes all testing dependencies via the starter. Ensure it is present:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>This includes: JUnit 5, Mockito, AssertJ, Spring Test, MockMvc, JSONPath, Hamcrest.
Run tests: ./mvnw test or ./gradlew test
Test slice selection:
@WebMvcTest(Controller.class) for controller-only tests@DataJpaTest for repository tests@SpringBootTest only for multi-layer integration testsMocking:
@MockBean in Spring slice tests (@WebMvcTest, @SpringBootTest)@Mock + @InjectMocks in plain unit tests (no Spring context)@MockBean in controller testsTest isolation:
@Transactional on test classes for auto-rollback (not manual deleteAll())@DirtiesContext used sparingly (only when beans are modified)@Sql for repeatable test data setupAssertions:
assertThat() used for all assertions (not JUnit assertEquals).andExpect() chains for HTTP response assertionsjsonPath() for JSON response validationConfiguration:
@ActiveProfiles("test") with test-specific application-test.ymlspring-boot-starter-test dependency present