263 lines
6.7 KiB
Markdown
263 lines
6.7 KiB
Markdown
# Spring Boot Testing Best Practices
|
|
|
|
## Choose the Right Test Type
|
|
|
|
Select the most efficient test annotation for your use case:
|
|
|
|
```java
|
|
// Use @DataJpaTest for repository-only tests (fastest)
|
|
@DataJpaTest
|
|
public class UserRepositoryTest { }
|
|
|
|
// Use @WebMvcTest for controller-only tests
|
|
@WebMvcTest(UserController.class)
|
|
public class UserControllerTest { }
|
|
|
|
// Use @SpringBootTest only for full integration testing
|
|
@SpringBootTest
|
|
public class UserServiceFullIntegrationTest { }
|
|
```
|
|
|
|
## Use @ServiceConnection for Container Management (Spring Boot 3.5+)
|
|
|
|
Prefer `@ServiceConnection` over manual `@DynamicPropertySource` for cleaner code:
|
|
|
|
```java
|
|
// Good - Spring Boot 3.5+
|
|
@TestConfiguration
|
|
public class TestConfig {
|
|
@Bean
|
|
@ServiceConnection
|
|
public PostgreSQLContainer<?> postgres() {
|
|
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
|
|
}
|
|
}
|
|
|
|
// Avoid - Manual property registration
|
|
@DynamicPropertySource
|
|
static void registerProperties(DynamicPropertyRegistry registry) {
|
|
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
|
// ... more properties
|
|
}
|
|
```
|
|
|
|
## Keep Tests Deterministic
|
|
|
|
Always initialize test data explicitly and never depend on test execution order:
|
|
|
|
```java
|
|
// Good - Explicit setup
|
|
@BeforeEach
|
|
void setUp() {
|
|
userRepository.deleteAll();
|
|
User user = new User();
|
|
user.setEmail("test@example.com");
|
|
userRepository.save(user);
|
|
}
|
|
|
|
// Avoid - Depending on other tests
|
|
@Test
|
|
void testUserExists() {
|
|
// Assumes previous test created a user
|
|
Optional<User> user = userRepository.findByEmail("test@example.com");
|
|
assertThat(user).isPresent();
|
|
}
|
|
```
|
|
|
|
## Use Transactional Tests Carefully
|
|
|
|
Mark test classes with `@Transactional` for automatic rollback, but understand the implications:
|
|
|
|
```java
|
|
@SpringBootTest
|
|
@Transactional // Automatically rolls back after each test
|
|
public class UserControllerIntegrationTest {
|
|
|
|
@Test
|
|
void shouldCreateUser() throws Exception {
|
|
// Changes will be rolled back after test
|
|
mockMvc.perform(post("/api/users")....)
|
|
.andExpect(status().isCreated());
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note**: Be aware that `@Transactional` test behavior may differ from production due to lazy loading and flush semantics.
|
|
|
|
## Organize Tests by Layer
|
|
|
|
Group related tests in separate classes to optimize context caching:
|
|
|
|
```java
|
|
// Repository tests (uses @DataJpaTest)
|
|
public class UserRepositoryTest { }
|
|
|
|
// Controller tests (uses @WebMvcTest)
|
|
public class UserControllerTest { }
|
|
|
|
// Service tests (uses mocks, no context)
|
|
public class UserServiceTest { }
|
|
|
|
// Full integration tests (uses @SpringBootTest)
|
|
public class UserFullIntegrationTest { }
|
|
```
|
|
|
|
## Use Meaningful Assertions
|
|
|
|
Leverage AssertJ for readable, fluent assertions:
|
|
|
|
```java
|
|
// Good - Clear, readable assertions
|
|
assertThat(user.getEmail())
|
|
.isEqualTo("test@example.com");
|
|
|
|
assertThat(users)
|
|
.hasSize(3)
|
|
.contains(expectedUser);
|
|
|
|
assertThatThrownBy(() -> userService.save(invalidUser))
|
|
.isInstanceOf(ValidationException.class)
|
|
.hasMessageContaining("Email is required");
|
|
|
|
// Avoid - JUnit assertions
|
|
assertEquals("test@example.com", user.getEmail());
|
|
assertTrue(users.size() == 3);
|
|
```
|
|
|
|
## Mock External Dependencies
|
|
|
|
Mock external services but use real databases for integration tests:
|
|
|
|
```java
|
|
// Good - Mock external services, use real DB
|
|
@SpringBootTest
|
|
@TestContainerConfig.class
|
|
public class OrderServiceTest {
|
|
|
|
@MockBean
|
|
private EmailService emailService;
|
|
|
|
@Autowired
|
|
private OrderRepository orderRepository;
|
|
|
|
@Test
|
|
void shouldSendConfirmationEmail() {
|
|
// Use real database, mock email service
|
|
Order order = new Order();
|
|
orderService.createOrder(order);
|
|
|
|
verify(emailService, times(1)).sendConfirmation(order);
|
|
}
|
|
}
|
|
|
|
// Avoid - Mocking the database layer
|
|
@Test
|
|
void shouldCreateOrder() {
|
|
when(orderRepository.save(any())).thenReturn(mockOrder);
|
|
// Tests don't verify actual database behavior
|
|
}
|
|
```
|
|
|
|
## Use Test Fixtures for Common Data
|
|
|
|
Create reusable test data builders:
|
|
|
|
```java
|
|
public class UserTestFixture {
|
|
public static User validUser() {
|
|
User user = new User();
|
|
user.setEmail("test@example.com");
|
|
user.setName("Test User");
|
|
return user;
|
|
}
|
|
|
|
public static User userWithEmail(String email) {
|
|
User user = validUser();
|
|
user.setEmail(email);
|
|
return user;
|
|
}
|
|
}
|
|
|
|
// Usage in tests
|
|
@Test
|
|
void shouldSaveUser() {
|
|
User user = UserTestFixture.validUser();
|
|
userRepository.save(user);
|
|
assertThat(userRepository.count()).isEqualTo(1);
|
|
}
|
|
```
|
|
|
|
## Document Complex Test Scenarios
|
|
|
|
Use `@DisplayName` and comments for complex test logic:
|
|
|
|
```java
|
|
@Test
|
|
@DisplayName("Should validate email format and reject duplicates with proper error message")
|
|
void shouldValidateEmailBeforePersisting() {
|
|
// Given: Two users with the same email
|
|
User user1 = new User();
|
|
user1.setEmail("test@example.com");
|
|
userRepository.save(user1);
|
|
|
|
User user2 = new User();
|
|
user2.setEmail("test@example.com"); // Duplicate email
|
|
|
|
// When: Attempting to save duplicate
|
|
// Then: Should throw exception with clear message
|
|
assertThatThrownBy(() -> {
|
|
userRepository.save(user2);
|
|
userRepository.flush();
|
|
})
|
|
.isInstanceOf(DataIntegrityViolationException.class)
|
|
.hasMessageContaining("unique constraint");
|
|
}
|
|
```
|
|
|
|
## Avoid Common Pitfalls
|
|
|
|
```java
|
|
// Avoid: Using @DirtiesContext without reason (forces context rebuild)
|
|
@SpringBootTest
|
|
@DirtiesContext // DON'T USE unless absolutely necessary
|
|
public class ProblematicTest { }
|
|
|
|
// Avoid: Mixing multiple profiles in same test suite
|
|
@SpringBootTest(properties = "spring.profiles.active=dev,test,prod")
|
|
public class MultiProfileTest { }
|
|
|
|
// Avoid: Starting containers manually
|
|
@SpringBootTest
|
|
public class ManualContainerTest {
|
|
static {
|
|
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>();
|
|
postgres.start(); // Avoid - use @ServiceConnection instead
|
|
}
|
|
}
|
|
|
|
// Good: Consistent configuration, minimal context switching
|
|
@SpringBootTest
|
|
@TestContainerConfig
|
|
public class ProperTest { }
|
|
```
|
|
|
|
## Test Naming Conventions
|
|
|
|
Convention: Use descriptive method names that start with `should` or `test` to make test intent explicit.
|
|
|
|
**Naming Rules:**
|
|
- **Prefix**: Start with `should` or `test` to clearly indicate test purpose
|
|
- **Structure**: Use camelCase for readability (no underscores)
|
|
- **Clarity**: Name should indicate what is being tested and the expected outcome
|
|
- **Example pattern**: `should[ExpectedBehavior]When[Condition]()`
|
|
|
|
**Examples:**
|
|
```
|
|
shouldReturnUsersJson()
|
|
shouldThrowNotFoundWhenIdDoesntExist()
|
|
shouldPropagateExceptionOnPersistenceError()
|
|
shouldSaveAndRetrieveUserFromDatabase()
|
|
shouldValidateEmailFormatBeforePersisting()
|
|
```
|
|
|
|
Apply these rules consistently across all integration test methods. |