feat: 根目录文档、脚本、gitignore

This commit is contained in:
2026-10-08 16:15:33 +08:00
commit e98660ce4e
284 changed files with 26838 additions and 0 deletions
@@ -0,0 +1,74 @@
# Spring Boot Test API Reference
## Test Annotations
**Spring Boot Test Annotations:**
- `@SpringBootTest`: Load full application context (use sparingly)
- `@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)`: Full test with random HTTP port
- `@SpringBootTest(webEnvironment = WebEnvironment.MOCK)`: Full test with mock web environment
- `@DataJpaTest`: Load only JPA components (repositories, entities)
- `@WebMvcTest`: Load only MVC layer (controllers, `@`ControllerAdvice)
- `@WebFluxTest`: Load only WebFlux layer (reactive controllers)
- `@JsonTest`: Load only JSON serialization components
- `@RestClientTest`: Load only REST client components
- `@AutoConfigureMockMvc`: Provide MockMvc bean in `@`SpringBootTest
- `@AutoConfigureWebTestClient`: Provide WebTestClient bean for WebFlux tests
- `@AutoConfigureTestDatabase`: Control test database configuration
**Testcontainer Annotations:**
- `@ServiceConnection`: Wire Testcontainer to Spring Boot test (Spring Boot 3.5+)
- `@DynamicPropertySource`: Register dynamic properties at runtime
- `@Container`: Mark field as Testcontainer (requires `@`Testcontainers)
- `@Testcontainers`: Enable Testcontainers lifecycle management
**Test Lifecycle Annotations:**
- `@BeforeEach`: Run before each test method
- `@AfterEach`: Run after each test method
- `@BeforeAll`: Run once before all tests in class (must be static)
- `@AfterAll`: Run once after all tests in class (must be static)
- `@DisplayName`: Custom test name for reports
- `@Disabled`: Skip test
- `@Tag`: Tag tests for selective execution
**Test Isolation Annotations:**
- `@DirtiesContext`: Clear Spring context after test (forces rebuild)
- `@DirtiesContext(classMode = ClassMode.AFTER_CLASS)`: Clear after entire class
## Common Test Utilities
**MockMvc Methods:**
- `mockMvc.perform(get("/path"))`: Perform GET request
- `mockMvc.perform(post("/path")).contentType(MediaType.APPLICATION_JSON)`: POST with content type
- `.andExpect(status().isOk())`: Assert HTTP status
- `.andExpect(content().contentType("application/json"))`: Assert content type
- `.andExpect(jsonPath("$.field").value("expected"))`: Assert JSON path value
**TestRestTemplate Methods:**
- `restTemplate.getForEntity("/path", String.class)`: GET request
- `restTemplate.postForEntity("/path", body, String.class)`: POST request
- `response.getStatusCode()`: Get HTTP status
- `response.getBody()`: Get response body
**WebTestClient Methods (Reactive):**
- `webTestClient.get().uri("/path").exchange()`: Perform GET request
- `.expectStatus().isOk()`: Assert status
- `.expectBody().jsonPath("$.field").isEqualTo(value)`: Assert JSON
## Test Slices Performance Guidelines
- **Unit tests**: Complete in <50ms each
- **Integration tests**: Complete in <500ms each
- **Maximize context caching** by grouping tests with same configuration
- **Reuse Testcontainers** at JVM level where possible
## Common Test Annotations Reference
| Annotation | Purpose | When to Use |
|------------|---------|-------------|
| `@SpringBootTest` | Full application context | Full integration tests only |
| `@DataJpaTest` | JPA components only | Repository and entity tests |
| `@WebMvcTest` | MVC layer only | Controller tests |
| `@WebFluxTest` | WebFlux layer only | Reactive controller tests |
| `@ServiceConnection` | Container integration | Spring Boot 3.5+ with Testcontainers |
| `@DynamicPropertySource` | Dynamic properties | Pre-3.5 or custom configuration |
| `@DirtiesContext` | Context cleanup | When absolutely necessary |
@@ -0,0 +1,263 @@
# 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.
@@ -0,0 +1,208 @@
# CI/CD Configuration
## GitHub Actions
### Basic Test Workflow
```yaml
name: Spring Boot Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: ./mvnw test -Dspring.profiles.active=test
- name: Generate test report
uses: dorny/test-reporter@v1
if: always()
with:
name: Maven Tests
path: target/surefire-reports/*.xml
reporter: java-junit
```
### With Testcontainers
```yaml
name: Tests with Testcontainers
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_USER: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Run tests
run: ./mvnw test
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
```
## GitLab CI
```yaml
stages:
- test
test:
stage: test
image: openjdk:17-jdk-slim
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
variables:
SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/testdb"
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
cache:
paths:
- .m2/repository/
script:
- ./mvnw test
artifacts:
when: always
reports:
junit: target/surefire-reports/TEST-*.xml
```
## Docker Compose for Local Testing
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: testdb
MYSQL_USER: test
MYSQL_PASSWORD: test
MYSQL_ROOT_PASSWORD: test
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
mysql_data:
```
Run tests with: `docker-compose up -d && ./mvnw test`
## Maven Test Profiles
```xml
<profiles>
<profile>
<id>unit-tests</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
<excludes>
<exclude>**/*IntegrationTest.java</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>integration-tests</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
```
Run with: `./mvnw test -Punit-tests` or `./mvnw verify -Pintegration-tests`
@@ -0,0 +1,266 @@
# Slice Testing Patterns
## Repository Slice Tests
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
// Arrange
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// Act
User saved = userRepository.save(user);
userRepository.flush();
// Assert
Optional<User> retrieved = userRepository.findByEmail("test@example.com");
assertThat(retrieved).isPresent();
assertThat(retrieved.get().getName()).isEqualTo("Test User");
}
@Test
void shouldFindAllActiveUsers() {
// Arrange
User activeUser = new User();
activeUser.setEmail("active@example.com");
activeUser.setActive(true);
User inactiveUser = new User();
inactiveUser.setEmail("inactive@example.com");
inactiveUser.setActive(false);
userRepository.saveAll(List.of(activeUser, inactiveUser));
// Act
List<User> activeUsers = userRepository.findByActiveTrue();
// Assert
assertThat(activeUsers).hasSize(1);
assertThat(activeUsers.get(0).getEmail()).isEqualTo("active@example.com");
}
@Test
void shouldDeleteUser() {
// Arrange
User user = new User();
user.setEmail("delete@example.com");
User saved = userRepository.save(user);
// Act
userRepository.deleteById(saved.getId());
userRepository.flush();
// Assert
assertThat(userRepository.findById(saved.getId())).isEmpty();
}
}
```
## Controller Slice Tests
```java
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UserService userService;
@Test
void shouldGetUserById() throws Exception {
// Arrange
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
user.setName("Test User");
when(userService.findById(1L)).thenReturn(Optional.of(user));
// Act & Assert
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("test@example.com"))
.andExpect(jsonPath("$.name").value("Test User"));
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
// Arrange
when(userService.findById(999L)).thenReturn(Optional.empty());
// Act & Assert
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
}
@Test
void shouldCreateUser() throws Exception {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail("new@example.com");
request.setName("New User");
User createdUser = new User();
createdUser.setId(1L);
createdUser.setEmail("new@example.com");
createdUser.setName("New User");
when(userService.createUser(any())).thenReturn(createdUser);
// Act & Assert
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("new@example.com"));
}
@Test
void shouldValidateRequest() throws Exception {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail(""); // Invalid
// Act & Assert
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
```
## JSON Slice Tests
```java
@JsonTest
class UserJsonSerializationTest {
@Autowired
private JacksonTester<User> json;
@Test
void shouldSerializeUser() throws JsonProcessingException {
// Arrange
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
user.setName("Test User");
// Act
JsonContent<User> result = json.write(user);
// Assert
assertThat(result).hasJsonPathValue("$.id", 1);
assertThat(result).hasJsonPathValue("$.email", "test@example.com");
assertThat(result).hasJsonPathValue("$.name", "Test User");
}
@Test
void shouldDeserializeUser() throws IOException {
// Arrange
String jsonContent = """
{
"id": 1,
"email": "test@example.com",
"name": "Test User"
}
""";
// Act
User result = json.parse(jsonContent).getObject();
// Assert
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getEmail()).isEqualTo("test@example.com");
assertThat(result.getName()).isEqualTo("Test User");
}
}
```
## WebFlux Controller Tests
```java
@WebFluxTest(UserController.class)
class ReactiveUserControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private UserService userService;
@Test
void shouldGetUserById() {
// Arrange
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
when(userService.findById(1L)).thenReturn(Mono.just(user));
// Act & Assert
webTestClient.get()
.uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.isEqualTo(user);
}
@Test
void shouldReturn404WhenUserNotFound() {
// Arrange
when(userService.findById(999L)).thenReturn(Mono.empty());
// Act & Assert
webTestClient.get()
.uri("/api/users/999")
.exchange()
.expectStatus().isNotFound();
}
}
```
## Testing ControllerAdvice
```java
@WebMvcTest(UserController.class)
class UserControllerExceptionTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldHandleUserNotFoundException() throws Exception {
// Arrange
when(userService.findById(999L))
.thenThrow(new UserNotFoundException("User not found"));
// Act & Assert
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("User not found"));
}
}
```
@@ -0,0 +1,107 @@
# Test Dependencies Setup
## Maven Dependencies
### Basic Testing Setup
```xml
<dependencies>
<!-- Spring Boot Test Starter (includes JUnit 5, Mockito, AssertJ) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers Core -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<!-- PostgreSQL Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<!-- MySQL Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<!-- Additional Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
```
### Gradle Dependencies
```kotlin
dependencies {
// Spring Boot Test Starter
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Testcontainers
testImplementation("org.testcontainers:junit-jupiter:1.19.0")
testImplementation("org.testcontainers:postgresql:1.19.0")
// Additional Dependencies
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
}
```
## Version Selection
- **Spring Boot 3.x**: Use Testcontainers 1.19.x+
- **Spring Boot 2.x**: Use Testcontainers 1.17.x
- Always check [Testcontainers Documentation](https://www.testcontainers.org/) for latest versions
## Optional Testing Dependencies
### H2 In-Memory Database
```xml
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
```
### WireMock for HTTP Mocking
```xml
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.5.2</version>
<scope>test</scope>
</dependency>
```
### Awaitility for Async Testing
```xml
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.0</version>
<scope>test</scope>
</dependency>
```
@@ -0,0 +1,157 @@
# Testcontainers Configuration
## Spring Boot 3.5+ `@ServiceConnection`
```java
@TestConfiguration
public class TestContainerConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
}
@Bean
@ServiceConnection
public GenericContainer<?> redisContainer() {
return new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
}
}
```
Apply with `@Import(TestContainerConfig.class)` on test classes.
## Traditional `@DynamicPropertySource`
```java
@Testcontainers
class UserServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
```
## Multiple Containers
```java
@Testcontainers
class MultiContainerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:16-alpine")
.withDatabaseName("testdb");
@Container
static GenericContainer<?> redis = new GenericContainer<>(
"redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", redis::getFirstMappedPort);
}
}
```
## Container Reuse Strategy
```java
@Testcontainers(disableWithoutDocker = true)
class ContainerConfig {
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true);
@BeforeAll
static void startAll() {
POSTGRES.start();
}
@AfterAll
static void stopAll() {
POSTGRES.stop();
}
}
```
Enable reuse with environment variable: `TESTCONTAINERS_REUSE_ENABLE=true`
## MySQL Container
```java
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>(
DockerImageName.parse("mysql:8.0"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
```
## MongoDB Container
```java
@Container
static MongoDBContainer<?> mongodb = new MongoDBContainer<>(
DockerImageName.parse("mongo:6.0"))
.withExposedPorts(27017);
```
## Kafka Container
```java
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
```
## Container Initialization
```java
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withInitScript("sql/init-test.sql") // Run init script
.withCommand("postgres", "-c", "max_connections=200"); // Custom config
```
## Network Configuration
```java
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:16-alpine")
.withNetwork(Network.SHARED)
.withNetworkAliases("pgdb"); // Access via hostname
```
@@ -0,0 +1,221 @@
# Unit Testing Patterns
## Basic Unit Test with Mockito
```java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void shouldFindUserByIdWhenExists() {
// Arrange
Long userId = 1L;
User user = new User();
user.setId(userId);
user.setEmail("test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isPresent();
assertThat(result.get().getEmail()).isEqualTo("test@example.com");
verify(userRepository, times(1)).findById(userId);
}
@Test
void shouldReturnEmptyWhenUserNotFound() {
// Arrange
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isEmpty();
verify(userRepository, times(1)).findById(userId);
}
@Test
void shouldThrowExceptionWhenCreatingUserWithInvalidEmail() {
// Arrange
CreateUserRequest request = new CreateUserRequest();
request.setEmail("invalid-email");
request.setName("Test User");
// Act & Assert
assertThatThrownBy(() -> userService.createUser(request))
.isInstanceOf(InvalidEmailException.class)
.hasMessage("Invalid email format");
verify(userRepository, never()).save(any());
}
}
```
## Testing Business Logic
```java
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductService productService;
@InjectMocks
private OrderService orderService;
@Test
void shouldCalculateTotalPrice() {
// Arrange
OrderItem item1 = new OrderItem();
item1.setPrice(10.0);
item1.setQuantity(2);
OrderItem item2 = new OrderItem();
item2.setPrice(15.0);
item2.setQuantity(1);
List<OrderItem> items = List.of(item1, item2);
// Act
double total = orderService.calculateTotal(items);
// Assert
assertThat(total).isEqualTo(35.0);
}
@Test
void shouldApplyDiscountForLargeOrders() {
// Arrange
Order order = new Order();
order.setTotal(1000.0);
// Act
orderService.applyDiscount(order, 10);
// Assert
assertThat(order.getTotal()).isEqualTo(900.0);
}
}
```
## Testing Exception Scenarios
```java
@Test
void shouldThrowExceptionWhenInsufficientStock() {
// Arrange
OrderRequest request = new OrderRequest();
request.setProductId(1L);
request.setQuantity(100);
when(productService.getStock(1L)).thenReturn(50);
// Act & Assert
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(InsufficientStockException.class)
.hasMessageContaining("Insufficient stock");
}
```
## Parameterized Tests
```java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
class ParameterizedUserServiceTest {
@ParameterizedTest
@ValueSource(strings = {"user@example.com", "test@test.com", "admin@domain.com"})
void shouldAcceptValidEmails(String email) {
assertThat(userService.isValidEmail(email)).isTrue();
}
@ParameterizedTest
@CsvSource({
"10, 2, 20",
"5, 3, 15",
"100, 0, 0"
})
void shouldCalculateTotalCorrectly(double price, int quantity, double expectedTotal) {
assertThat(orderService.calculateTotal(price, quantity))
.isEqualTo(expectedTotal);
}
@ParameterizedTest
@MethodSource("provideInvalidEmails")
void shouldRejectInvalidEmails(String email) {
assertThat(userService.isValidEmail(email)).isFalse();
}
private static Stream<String> provideInvalidEmails() {
return Stream.of(
"invalid",
"@example.com",
"user@",
"user @example.com"
);
}
}
```
## Test Fixtures
```java
class UserTestFixture {
public static User createTestUser() {
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
user.setName("Test User");
return user;
}
public static CreateUserRequest createTestRequest() {
CreateUserRequest request = new CreateUserRequest();
request.setEmail("new@example.com");
request.setName("New User");
return request;
}
}
class UserServiceTest {
@Test
void shouldCreateUser() {
CreateUserRequest request = UserTestFixture.createTestRequest();
User result = userService.createUser(request);
assertThat(result.getEmail()).isEqualTo(request.getEmail());
}
}
```
@@ -0,0 +1,340 @@
# Spring Boot Testing Workflow Patterns
## Complete Database Integration Test Pattern
**Scenario**: Test a JPA repository with a real PostgreSQL database using Testcontainers.
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUserFromDatabase() {
// Arrange
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// Act
User saved = userRepository.save(user);
userRepository.flush();
Optional<User> retrieved = userRepository.findByEmail("test@example.com");
// Assert
assertThat(retrieved).isPresent();
assertThat(retrieved.get().getName()).isEqualTo("Test User");
}
@Test
void shouldThrowExceptionForDuplicateEmail() {
// Arrange
User user1 = new User();
user1.setEmail("duplicate@example.com");
user1.setName("User 1");
User user2 = new User();
user2.setEmail("duplicate@example.com");
user2.setName("User 2");
userRepository.save(user1);
// Act & Assert
assertThatThrownBy(() -> {
userRepository.save(user2);
userRepository.flush();
}).isInstanceOf(DataIntegrityViolationException.class);
}
}
```
## Complete REST API Integration Test Pattern
**Scenario**: Test REST controllers with full Spring context using MockMvc.
```java
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldCreateUserAndReturn201() throws Exception {
User user = new User();
user.setEmail("newuser@example.com");
user.setName("New User");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("newuser@example.com"))
.andExpect(jsonPath("$.name").value("New User"));
}
@Test
void shouldReturnUserById() throws Exception {
// Arrange
User user = new User();
user.setEmail("existing@example.com");
user.setName("Existing User");
User saved = userRepository.save(user);
// Act & Assert
mockMvc.perform(get("/api/users/" + saved.getId())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("existing@example.com"))
.andExpect(jsonPath("$.name").value("Existing User"));
}
@Test
void shouldReturnNotFoundForMissingUser() throws Exception {
mockMvc.perform(get("/api/users/99999")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
void shouldUpdateUserAndReturn200() throws Exception {
// Arrange
User user = new User();
user.setEmail("update@example.com");
user.setName("Original Name");
User saved = userRepository.save(user);
User updateData = new User();
updateData.setName("Updated Name");
// Act & Assert
mockMvc.perform(put("/api/users/" + saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateData)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated Name"));
}
@Test
void shouldDeleteUserAndReturn204() throws Exception {
// Arrange
User user = new User();
user.setEmail("delete@example.com");
user.setName("To Delete");
User saved = userRepository.save(user);
// Act & Assert
mockMvc.perform(delete("/api/users/" + saved.getId()))
.andExpect(status().isNoContent());
assertThat(userRepository.findById(saved.getId())).isEmpty();
}
}
```
## Service Layer Integration Test Pattern
**Scenario**: Test business logic with mocked repository.
```java
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void shouldFindUserByIdWhenExists() {
// Arrange
Long userId = 1L;
User user = new User();
user.setId(userId);
user.setEmail("test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isPresent();
assertThat(result.get().getEmail()).isEqualTo("test@example.com");
verify(userRepository, times(1)).findById(userId);
}
@Test
void shouldReturnEmptyWhenUserNotFound() {
// Arrange
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isEmpty();
verify(userRepository, times(1)).findById(userId);
}
@Test
void shouldThrowExceptionWhenSavingInvalidUser() {
// Arrange
User invalidUser = new User();
invalidUser.setEmail("invalid-email");
when(userRepository.save(invalidUser))
.thenThrow(new DataIntegrityViolationException("Invalid email"));
// Act & Assert
assertThatThrownBy(() -> userService.save(invalidUser))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
```
## Reactive WebFlux Integration Test Pattern
**Scenario**: Test WebFlux controllers with WebTestClient.
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
public class ReactiveUserControllerIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldReturnUserAsJsonReactive() {
// Arrange
User user = new User();
user.setEmail("reactive@example.com");
user.setName("Reactive User");
User saved = userRepository.save(user);
// Act & Assert
webTestClient.get()
.uri("/api/users/" + saved.getId())
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.email").isEqualTo("reactive@example.com")
.jsonPath("$.name").isEqualTo("Reactive User");
}
@Test
void shouldReturnArrayOfUsers() {
// Arrange
User user1 = new User();
user1.setEmail("user1@example.com");
user1.setName("User 1");
User user2 = new User();
user2.setEmail("user2@example.com");
user2.setName("User 2");
userRepository.saveAll(List.of(user1, user2));
// Act & Assert
webTestClient.get()
.uri("/api/users")
.exchange()
.expectStatus().isOk()
.expectBodyList(User.class)
.hasSize(2);
}
}
```
## Testcontainers Configuration Patterns
### `@`ServiceConnection Pattern (Spring Boot 3.5+)
```java
@TestConfiguration
public class TestContainerConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// Do not call start(); Spring Boot will manage lifecycle for @ServiceConnection beans
}
}
```
### `@`DynamicPropertySource Pattern (Legacy)
```java
public class SharedContainers {
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@BeforeAll
static void startAll() {
POSTGRES.start();
}
@AfterAll
static void stopAll() {
POSTGRES.stop();
}
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
```
### Slice Tests with Testcontainers
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class MyRepositoryIntegrationTest {
// repository tests
}
```