Files
gh-giuseppe-trisciuoglio-de…/skills/spring-boot-test-patterns/references/best-practices.md
2025-11-29 18:28:34 +08:00

6.7 KiB

Spring Boot Testing Best Practices

Choose the Right Test Type

Select the most efficient test annotation for your use case:

// 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:

// 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:

// 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:

@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:

// 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:

// 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:

// 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:

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:

@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

// 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.