A testing strategy that actually works — when to use @WebMvcTest vs @SpringBootTest, mocking with Mockito, testcontainers for real DB tests, and the test pyramid for Spring Boot.
Most Spring Boot test suites I've reviewed have the same problem: too many slow @SpringBootTest tests and not enough focused unit tests. Here's the testing pyramid I actually follow.
1@ExtendWith(MockitoExtension.class)
2class UserServiceTest {
3
4 @Mock UserRepository userRepo;
5 @Mock EmailService emailService;
6 @InjectMocks UserService userService;
7
8 @Test
9 void createUser_ShouldSendWelcomeEmail() {
10 User user = new User("ayush@test.com", "Ayush");
11 when(userRepo.save(any())).thenReturn(user);
12
13 userService.createUser(user);
14
15 verify(emailService).sendWelcome(user.getEmail());
16 }
17
18 @Test
19 void createUser_DuplicateEmail_ShouldThrow() {
20 when(userRepo.existsByEmail(any())).thenReturn(true);
21 assertThrows(DuplicateResourceException.class,
22 () -> userService.createUser(new User("existing@test.com", "Test")));
23 }
24}1@WebMvcTest(UserController.class)
2class UserControllerTest {
3
4 @Autowired MockMvc mockMvc;
5 @MockBean UserService userService;
6
7 @Test
8 void getUser_ExistingId_Returns200() throws Exception {
9 when(userService.findById(1L)).thenReturn(new User("ayush@test.com", "Ayush"));
10
11 mockMvc.perform(get("/api/users/1")
12 .contentType(MediaType.APPLICATION_JSON))
13 .andExpect(status().isOk())
14 .andExpect(jsonPath("$.email").value("ayush@test.com"));
15 }
16
17 @Test
18 void getUser_NotFound_Returns404() throws Exception {
19 when(userService.findById(99L)).thenThrow(new ResourceNotFoundException("User", 99L));
20
21 mockMvc.perform(get("/api/users/99"))
22 .andExpect(status().isNotFound());
23 }
24}1@DataJpaTest
2@AutoConfigureTestDatabase(replace = Replace.NONE)
3@Testcontainers
4class UserRepositoryIT {
5
6 @Container
7 static PostgreSQLContainer<?> postgres =
8 new PostgreSQLContainer<>("postgres:15-alpine");
9
10 @DynamicPropertySource
11 static void properties(DynamicPropertyRegistry registry) {
12 registry.add("spring.datasource.url", postgres::getJdbcUrl);
13 registry.add("spring.datasource.username", postgres::getUsername);
14 registry.add("spring.datasource.password", postgres::getPassword);
15 }
16
17 @Autowired UserRepository userRepo;
18
19 @Test
20 void findByEmail_ShouldReturnUser() {
21 userRepo.save(new User("ayush@test.com", "Ayush"));
22 assertTrue(userRepo.findByEmail("ayush@test.com").isPresent());
23 }
24}Use @WebMvcTest and @DataJpaTest (slice tests) instead of @SpringBootTest whenever possible. Slice tests start in ~2 seconds vs 15+ seconds for the full context. Reserve @SpringBootTest for end-to-end integration tests only.
1// Builder pattern for test data — readable and maintainable
2public class UserTestBuilder {
3
4 private Long id = 1L;
5 private String email = "test@example.com";
6 private String name = "Test User";
7 private Role role = Role.USER;
8 private boolean active = true;
9
10 public static UserTestBuilder aUser() { return new UserTestBuilder(); }
11
12 public UserTestBuilder withEmail(String email) { this.email = email; return this; }
13 public UserTestBuilder withRole(Role role) { this.role = role; return this; }
14 public UserTestBuilder inactive() { this.active = false; return this; }
15
16 public User build() {
17 return User.builder().id(id).email(email)
18 .name(name).role(role).active(active).build();
19 }
20}
21
22// Usage in tests — reads like a sentence
23User admin = aUser().withEmail("admin@co.com").withRole(ADMIN).build();
24User banned = aUser().withEmail("banned@co.com").inactive().build();1@SpringBootTest
2@AutoConfigureWireMock(port = 0) // random port
3class PaymentGatewayTest {
4
5 @Autowired PaymentService paymentService;
6 @Value("${wiremock.server.port}") int wireMockPort;
7
8 @Test
9 void processPayment_SuccessResponse_ReturnsConfirmation() {
10 stubFor(post(urlEqualTo("/payments"))
11 .willReturn(aResponse()
12 .withStatus(200)
13 .withHeader("Content-Type", "application/json")
14 .withBody("""
15 {"transactionId": "txn-123", "status": "APPROVED"}
16 """)));
17
18 PaymentResult result = paymentService.process(new PaymentRequest(100.0));
19
20 assertThat(result.getStatus()).isEqualTo("APPROVED");
21 verify(postRequestedFor(urlEqualTo("/payments")));
22 }
23
24 @Test
25 void processPayment_GatewayTimeout_ThrowsException() {
26 stubFor(post(urlEqualTo("/payments"))
27 .willReturn(aResponse().withFixedDelay(5000))); // 5s delay
28
29 assertThrows(TimeoutException.class,
30 () -> paymentService.process(new PaymentRequest(100.0)));
31 }
32}1@WebMvcTest(UserController.class)
2class UserControllerSecurityTest {
3
4 @Autowired MockMvc mockMvc;
5
6 @Test
7 void getUser_UnauthenticatedRequest_Returns401() throws Exception {
8 mockMvc.perform(get("/api/users/1"))
9 .andExpect(status().isUnauthorized());
10 }
11
12 @Test
13 @WithMockUser(username = "user@test.com", roles = "USER")
14 void getUser_AuthenticatedUser_Returns200() throws Exception {
15 mockMvc.perform(get("/api/users/1"))
16 .andExpect(status().isOk());
17 }
18
19 @Test
20 @WithMockUser(roles = "USER")
21 void adminEndpoint_UserRole_Returns403() throws Exception {
22 mockMvc.perform(get("/api/admin/users"))
23 .andExpect(status().isForbidden());
24 }
25}A good test suite is the foundation of confident refactoring. Without it, every change to your Spring Boot app is a leap of faith. The investment in slice tests, Testcontainers, and WireMock pays off every time you ship a change without a production incident.
More in Backend Engineering