feat: 根目录文档、脚本、gitignore
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: spring-boot-test-patterns
|
||||
description: Provides comprehensive testing patterns for Spring Boot applications covering unit, integration, slice, and container-based testing with JUnit 5, Mockito, Testcontainers, and performance optimization. Use when writing tests, @Test methods, @MockBean mocks, or implementing test suites for Spring Boot applications.
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
||||
---
|
||||
|
||||
# Spring Boot Testing Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive guidance for writing robust test suites for Spring Boot applications using JUnit 5, Mockito, Testcontainers, and performance-optimized slice testing patterns.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Writing unit tests for services or repositories with mocked dependencies
|
||||
- Implementing integration tests with real databases via Testcontainers
|
||||
- Testing REST APIs with `@WebMvcTest` or MockMvc
|
||||
- Configuring `@ServiceConnection` for container management in Spring Boot 3.5+
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Test Type | Annotation | Target Time | Use Case |
|
||||
|-----------|------------|-------------|----------|
|
||||
| **Unit Tests** | `@ExtendWith(MockitoExtension.class)` | < 50ms | Business logic without Spring context |
|
||||
| **Repository Tests** | `@DataJpaTest` | < 100ms | Database operations with minimal context |
|
||||
| **Controller Tests** | `@WebMvcTest` / `@WebFluxTest` | < 100ms | REST API layer testing |
|
||||
| **Integration Tests** | `@SpringBootTest` | < 500ms | Full application context with containers |
|
||||
| **Testcontainers** | `@ServiceConnection` / `@Testcontainers` | Varies | Real database/message broker containers |
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Test Architecture Philosophy
|
||||
|
||||
1. **Unit Tests** — Fast, isolated tests without Spring context (< 50ms)
|
||||
2. **Slice Tests** — Minimal Spring context for specific layers (< 100ms)
|
||||
3. **Integration Tests** — Full Spring context with real dependencies (< 500ms)
|
||||
|
||||
### Key Annotations
|
||||
|
||||
**Spring Boot Test:**
|
||||
- `@SpringBootTest` — Full application context (use sparingly)
|
||||
- `@DataJpaTest` — JPA components only (repositories, entities)
|
||||
- `@WebMvcTest` — MVC layer only (controllers, `@ControllerAdvice`)
|
||||
- `@WebFluxTest` — WebFlux layer only (reactive controllers)
|
||||
- `@JsonTest` — JSON serialization components only
|
||||
|
||||
**Testcontainers:**
|
||||
- `@ServiceConnection` — Wire Testcontainer to Spring Boot (3.5+)
|
||||
- `@DynamicPropertySource` — Register dynamic properties at runtime
|
||||
- `@Testcontainers` — Enable Testcontainers lifecycle management
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Unit Testing Pattern
|
||||
|
||||
Test business logic with mocked dependencies:
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldFindUserByIdWhenExists() {
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
Optional<User> result = userService.findById(1L);
|
||||
assertThat(result).isPresent();
|
||||
verify(userRepository).findById(1L);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [unit-testing.md](references/unit-testing.md) for advanced patterns.
|
||||
|
||||
### 2. Slice Testing Pattern
|
||||
|
||||
Use focused test slices for specific layers:
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@TestContainerConfig
|
||||
class UserRepositoryIntegrationTest {
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void shouldSaveAndRetrieveUser() {
|
||||
User saved = userRepository.save(user);
|
||||
assertThat(userRepository.findByEmail("test@example.com")).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [slice-testing.md](references/slice-testing.md) for all slice patterns.
|
||||
|
||||
### 3. REST API Testing Pattern
|
||||
|
||||
Test controllers with MockMvc:
|
||||
|
||||
```java
|
||||
@WebMvcTest(UserController.class)
|
||||
class UserControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldGetUserById() throws Exception {
|
||||
mockMvc.perform(get("/api/users/1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.email").value("test@example.com"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Testcontainers with `@ServiceConnection`
|
||||
|
||||
Configure containers with Spring Boot 3.5+:
|
||||
|
||||
```java
|
||||
@TestConfiguration
|
||||
public class TestContainerConfig {
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
public PostgreSQLContainer<?> postgresContainer() {
|
||||
return new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apply with `@Import(TestContainerConfig.class)` on test classes.
|
||||
See [testcontainers-setup.md](references/testcontainers-setup.md) for detailed configuration.
|
||||
|
||||
### 5. Add Dependencies
|
||||
|
||||
Include required testing dependencies:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.19.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
See [test-dependencies.md](references/test-dependencies.md) for complete dependency list.
|
||||
|
||||
### 6. Configure CI/CD
|
||||
|
||||
Set up GitHub Actions for automated testing:
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
docker:
|
||||
image: docker:20-dind
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
- name: Run tests
|
||||
run: ./mvnw test
|
||||
```
|
||||
|
||||
See [ci-cd-configuration.md](references/ci-cd-configuration.md) for full CI/CD patterns.
|
||||
|
||||
### Validation Checkpoints
|
||||
|
||||
After implementing tests, verify:
|
||||
- Container running: `docker ps` (look for testcontainer images)
|
||||
- Context loaded: check startup logs for "Started Application in X.XX seconds"
|
||||
- Test isolation: run tests individually and confirm no cross-contamination
|
||||
|
||||
## Examples
|
||||
|
||||
### Full Integration Test with `@ServiceConnection`
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@Import(TestContainerConfig.class)
|
||||
class OrderServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void shouldCreateOrderForExistingUser() {
|
||||
User user = userRepository.save(User.builder()
|
||||
.email("order-test@example.com")
|
||||
.build());
|
||||
|
||||
Order order = orderService.createOrder(user.getId(), List.of(
|
||||
new OrderItem("SKU-001", 2)
|
||||
));
|
||||
|
||||
assertThat(order.getId()).isNotNull();
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `@DataJpaTest` with Real Database
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@TestContainerConfig
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void shouldFindByEmail() {
|
||||
userRepository.save(User.builder()
|
||||
.email("jpa-test@example.com")
|
||||
.build());
|
||||
assertThat(userRepository.findByEmail("jpa-test@example.com"))
|
||||
.isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [workflow-patterns.md](references/workflow-patterns.md) for complete end-to-end examples.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use the right test type**: `@DataJpaTest` for repositories, `@WebMvcTest` for controllers, `@SpringBootTest` only for full integration
|
||||
- **Prefer `@ServiceConnection`** on Spring Boot 3.5+ for cleaner container management over `@DynamicPropertySource`
|
||||
- **Keep tests deterministic**: Initialize all test data explicitly in `@BeforeEach`
|
||||
- **Organize by layer**: Group tests by layer to maximize context caching
|
||||
- **Reuse Testcontainers** at JVM level (`withReuse(true)` + `TESTCONTAINERS_REUSE_ENABLE=true`)
|
||||
- **Avoid `@DirtiesContext`**: Forces context rebuild, significantly hurts performance
|
||||
- **Mock external services**, use real databases only when necessary
|
||||
- **Performance targets**: Unit < 50ms, Slice < 100ms, Integration < 500ms
|
||||
|
||||
## Constraints and Warnings
|
||||
|
||||
- Never use `@DirtiesContext` unless absolutely necessary (forces context rebuild)
|
||||
- Avoid mixing `@MockBean` with different configurations (creates separate contexts)
|
||||
- Testcontainers require Docker; ensure CI/CD pipelines have Docker support
|
||||
- Do not rely on test execution order; each test must be independent
|
||||
- Be cautious with `@TestPropertySource` (creates separate contexts)
|
||||
- Do not use `@SpringBootTest` for unit tests; use plain Mockito instead
|
||||
- Context caching can be invalidated by different `@MockBean` configurations
|
||||
- Avoid static mutable state in tests (causes flaky tests)
|
||||
|
||||
## References
|
||||
|
||||
- **[test-dependencies.md](references/test-dependencies.md)** — Maven/Gradle test dependencies
|
||||
- **[unit-testing.md](references/unit-testing.md)** — Unit testing with Mockito patterns
|
||||
- **[slice-testing.md](references/slice-testing.md)** — Repository, controller, and JSON slice tests
|
||||
- **[testcontainers-setup.md](references/testcontainers-setup.md)** — Testcontainers configuration patterns
|
||||
- **[ci-cd-configuration.md](references/ci-cd-configuration.md)** — GitHub Actions, GitLab CI, Docker Compose
|
||||
- **[api-reference.md](references/api-reference.md)** — Complete test annotations and utilities
|
||||
- **[best-practices.md](references/best-practices.md)** — Testing patterns and optimization
|
||||
- **[workflow-patterns.md](references/workflow-patterns.md)** — Complete integration test examples
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user