Spring Boot Testing Comprehensive Guide
Testing philosophy: A well-tested application gives you
confidence to refactor, deploy, and make changes without fear of
breaking existing functionality.
The Test Pyramid
/\
/ \
/ E2E \ Few end-to-end tests
/______\ - Slow
/ \ - Test entire application
/ Integrat\ - Full Spring context
/___________\
/ \ Many integration tests
/ Unit Tests \ - Medium speed
/_______________\ - Test Spring components together
- @SpringBootTest, @DataJpaTest
Lots of unit tests - Fast
- Mock dependencies - Test single class in isolation
- No Spring context - Use Mockito
- Test business logic
Unit Testing: Test a Single Class
Testing a Service with Mocked Dependencies
// The service to test
@Service
public class UserService {
private final UserRepository repository;
private final EmailService emailService;
public UserService(UserRepository repository, EmailService emailService) {
this.repository = repository;
this.emailService = emailService;
}
public UserResponse createUser(CreateUserRequest request) {
User user = new User();
user.setName(request.name());
user.setEmail(request.email());
User saved = repository.save(user);
emailService.sendWelcomeEmail(saved.getEmail());
return new UserResponse(saved.getId(), saved.getName(), saved.getEmail());
}
}
// Unit test
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository repository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
public void testCreateUserSuccessfully() {
// Arrange (Given)
CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
User savedUser = new User(1L, "John", "john@example.com");
when(repository.save(any(User.class))).thenReturn(savedUser);
// Act (When)
UserResponse response = userService.createUser(request);
// Assert (Then)
assertEquals("John", response.name());
assertEquals("john@example.com", response.email());
// Verify interactions
verify(repository).save(any(User.class));
verify(emailService).sendWelcomeEmail("john@example.com");
}
@Test
public void testCreateUserEmailServiceFailure() {
// Test what happens when email sending fails
CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
when(repository.save(any(User.class))).thenReturn(new User(1L, "John", "john@example.com"));
doThrow(new EmailException("SMTP unavailable"))
.when(emailService).sendWelcomeEmail(any());
// The service should still return user, not crash
assertThrows(EmailException.class, () -> userService.createUser(request));
}
}
Integration Testing: Test Components Together
Testing the Database Layer with @DataJpaTest
@DataJpaTest
public class UserRepositoryTest {
@Autowired
private UserRepository repository;
@Autowired
private TestEntityManager entityManager;
@Test
public void testFindByEmail() {
// Arrange
User user = new User();
user.setName("John");
user.setEmail("john@example.com");
entityManager.persistAndFlush(user);
// Act
Optional<User> found = repository.findByEmail("john@example.com");
// Assert
assertTrue(found.isPresent());
assertEquals("John", found.get().getName());
}
@Test
public void testFindByEmailNotFound() {
// Act
Optional<User> found = repository.findByEmail("nonexistent@example.com");
// Assert
assertFalse(found.isPresent());
}
@Test
public void testFindByNameContainingIgnoreCase() {
// Arrange
User user1 = new User(null, "John Doe", "john@example.com");
User user2 = new User(null, "Jane Smith", "jane@example.com");
entityManager.persistAndFlush(user1);
entityManager.persistAndFlush(user2);
// Act
List<User> results = repository.findByNameContainingIgnoreCase("john");
// Assert
assertEquals(1, results.size());
assertEquals("John Doe", results.get(0).getName());
}
}
Testing REST Controllers with MockMvc
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
public void testGetUserSuccess() throws Exception {
// Arrange
UserResponse response = new UserResponse(1L, "John", "john@example.com");
when(userService.findById(1L)).thenReturn(Optional.of(response));
// Act & Assert
mockMvc.perform(get("/api/v1/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("John"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
@Test
public void testGetUserNotFound() throws Exception {
// Arrange
when(userService.findById(999L)).thenReturn(Optional.empty());
// Act & Assert
mockMvc.perform(get("/api/v1/users/999"))
.andExpect(status().isNotFound());
}
@Test
public void testCreateUserSuccess() throws Exception {
// Arrange
CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
UserResponse response = new UserResponse(1L, "John", "john@example.com");
when(userService.create(any())).thenReturn(response);
// Act & Assert
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "John", "email": "john@example.com"}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(1));
}
@Test
public void testCreateUserValidationError() throws Exception {
// Invalid input: missing name
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email": "john@example.com"}
"""))
.andExpect(status().isBadRequest());
}
}
Full Integration Testing with @SpringBootTest
@SpringBootTest
@AutoConfigureMockMvc
public class UserIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
public void cleanup() {
userRepository.deleteAll();
}
@Test
public void testCreateAndRetrieveUser() throws Exception {
// Create a user via API
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "John Doe", "email": "john@example.com"}
"""))
.andExpect(status().isCreated());
// Retrieve the user
mockMvc.perform(get("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].name").value("John Doe"));
// Verify in database
Optional<User> user = userRepository.findByEmail("john@example.com");
assertTrue(user.isPresent());
assertEquals("John Doe", user.get().getName());
}
}
Common Testing Annotations
| Annotation | Purpose | Use when |
|---|---|---|
@ExtendWith(MockitoExtension.class) |
Enable Mockito for unit tests | Testing a single class with mocks |
@Mock |
Create a mock object | Need to replace a dependency |
@InjectMocks |
Inject mocks into class under test | Auto-inject mocks into constructor |
@SpringBootTest |
Load full application context | Full integration testing |
@WebMvcTest |
Test only REST controllers | Controller unit testing |
@DataJpaTest |
Test only repository/database layer | Repository unit testing |
@MockBean |
Mock a Spring bean | Replace bean in Spring context |
@BeforeEach |
Run before each test | Setup/cleanup per test |
@BeforeAll |
Run once before all tests | Heavy setup (once per class) |
@DisplayName |
Human-readable test name | Better test reports |
Testing Best Practices
- ✅ One assertion per test (when possible)
- ✅ Use descriptive test names: testCreateUserWithValidInput()
- ✅ Use Given-When-Then pattern for clarity
- ✅ Test both success and failure cases
- ✅ Use @BeforeEach to setup each test independently
- ✅ Mock external dependencies (database, API calls)
- ✅ Test edge cases and boundaries
- ✅ Keep tests fast (use unit tests, not integration tests for everything)
- ✅ Test at the right level (pyramid principle)
- ❌ Don't test framework behavior (Spring knows it works)
- ❌ Don't test library code (trust JUnit, Mockito)
- ❌ Don't make tests interdependent
- ❌ Don't test too much in one test
Measuring Test Coverage
Use JaCoCo to measure how much of your code is tested:
// pom.xml
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
// Run: ./mvnw clean test
// View report: target/site/jacoco/index.html
Coverage goals: 80%+ for business logic, 50%+ overall is reasonable
See Basic Testing Guide →Continue learning
