Backend EngineeringDecember 1, 20247 min read

Spring Boot Testing: Unit, Integration, and Slice Tests

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.

JavaSpring BootTestingJUnitMockitoTestcontainers

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.

Unit Tests with Mockito

UserServiceTest.java
java
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}

@WebMvcTest for Controller Layer

UserControllerTest.java
java
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}

Integration Tests with Testcontainers

UserRepositoryIT.java
java
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.

Test Data Builders

UserTestBuilder.java
java
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();

WireMock for External API Tests

PaymentGatewayTest.java
java
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}

Testing Spring Security

SecurityTest.java
java
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