Initial commit
This commit is contained in:
626
skills/spring-boot/spring-boot-test-patterns/SKILL.md
Normal file
626
skills/spring-boot/spring-boot-test-patterns/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
name: spring-boot-test-patterns
|
||||
description: Comprehensive testing patterns for Spring Boot applications including unit, integration, slice, and container-based testing with JUnit 5, Mockito, Testcontainers, and performance optimization. Use when implementing robust test suites for Spring Boot applications.
|
||||
category: testing
|
||||
tags: [spring-boot, java, testing, junit5, mockito, testcontainers, integration-testing, unit-testing, test-slices]
|
||||
version: 1.5.0
|
||||
language: java
|
||||
license: Complete terms in LICENSE.txt
|
||||
allowed-tools: Read, Write, Bash
|
||||
---
|
||||
|
||||
# Spring Boot Testing Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive guidance for writing robust test suites for Spring Boot applications. It covers unit testing with Mockito, integration testing with Testcontainers, performance-optimized slice testing patterns, and best practices for maintaining fast feedback loops.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Writing unit tests for services, repositories, or utilities
|
||||
- Implementing integration tests with real databases using Testcontainers
|
||||
- Setting up performance-optimized test slices (@DataJpaTest, @WebMvcTest)
|
||||
- Configuring Spring Boot 3.5+ @ServiceConnection for container management
|
||||
- Testing REST APIs with MockMvc, TestRestTemplate, or WebTestClient
|
||||
- Optimizing test performance through context caching and container reuse
|
||||
- Setting up CI/CD pipelines for integration tests
|
||||
- Implementing comprehensive test strategies for monolithic or microservices applications
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Test Architecture Philosophy
|
||||
|
||||
Spring Boot testing follows a layered approach with distinct test types:
|
||||
|
||||
**1. Unit Tests**
|
||||
- Fast, isolated tests without Spring context
|
||||
- Use Mockito for dependency injection
|
||||
- Focus on business logic validation
|
||||
- Target completion time: < 50ms per test
|
||||
|
||||
**2. Slice Tests**
|
||||
- Minimal Spring context loading for specific layers
|
||||
- Use @DataJpaTest for repository tests
|
||||
- Use @WebMvcTest for controller tests
|
||||
- Use @WebFluxTest for reactive controller tests
|
||||
- Target completion time: < 100ms per test
|
||||
|
||||
**3. Integration Tests**
|
||||
- Full Spring context with real dependencies
|
||||
- Use @SpringBootTest with @ServiceConnection containers
|
||||
- Test complete application flows
|
||||
- Target completion time: < 500ms per test
|
||||
|
||||
### Key Testing Annotations
|
||||
|
||||
**Spring Boot Test Annotations:**
|
||||
- `@SpringBootTest`: Load full application context (use sparingly)
|
||||
- `@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
|
||||
|
||||
**Testcontainer Annotations:**
|
||||
- `@ServiceConnection`: Wire Testcontainer to Spring Boot test (Spring Boot 3.5+)
|
||||
- `@DynamicPropertySource`: Register dynamic properties at runtime
|
||||
- `@Testcontainers`: Enable Testcontainers lifecycle management
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Maven Dependencies
|
||||
|
||||
```xml
|
||||
<dependencies>
|
||||
<!-- Spring Boot Test Starter -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testcontainers -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.19.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.19.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Additional Testing 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")
|
||||
}
|
||||
```
|
||||
|
||||
## Instructions
|
||||
|
||||
### Unit Testing Pattern
|
||||
|
||||
Test business logic with mocked dependencies:
|
||||
|
||||
```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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Slice Testing Pattern
|
||||
|
||||
Use focused test slices for specific layers:
|
||||
|
||||
```java
|
||||
// Repository test with minimal context
|
||||
@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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### REST API Testing Pattern
|
||||
|
||||
Test controllers with MockMvc for faster execution:
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
public class UserControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@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"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testcontainers with @ServiceConnection
|
||||
|
||||
Configure containers with 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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Unit Test
|
||||
|
||||
```java
|
||||
@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);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test with Testcontainers
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@TestContainerConfig
|
||||
public class OrderServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@MockBean
|
||||
private PaymentService paymentService;
|
||||
|
||||
@Test
|
||||
void shouldCreateOrderWithRealDatabase() {
|
||||
// Arrange
|
||||
User user = new User();
|
||||
user.setEmail("customer@example.com");
|
||||
user.setName("John Doe");
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
OrderRequest request = new OrderRequest();
|
||||
request.setUserId(savedUser.getId());
|
||||
request.setItems(List.of(
|
||||
new OrderItemRequest(1L, 2),
|
||||
new OrderItemRequest(2L, 1)
|
||||
));
|
||||
|
||||
when(paymentService.processPayment(any())).thenReturn(true);
|
||||
|
||||
// Act
|
||||
OrderResponse response = orderService.createOrder(request);
|
||||
|
||||
// Assert
|
||||
assertThat(response.getOrderId()).isNotNull();
|
||||
assertThat(response.getStatus()).isEqualTo("COMPLETED");
|
||||
verify(paymentService, times(1)).processPayment(any());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reactive Test Pattern
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@AutoConfigureWebTestClient
|
||||
public class ReactiveUserControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@Test
|
||||
void shouldReturnUserAsJsonReactive() {
|
||||
// Arrange
|
||||
User user = new User();
|
||||
user.setEmail("reactive@example.com");
|
||||
user.setName("Reactive User");
|
||||
|
||||
// Act & Assert
|
||||
webTestClient.get()
|
||||
.uri("/api/users/1")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody()
|
||||
.jsonPath("$.email").isEqualTo("reactive@example.com")
|
||||
.jsonPath("$.name").isEqualTo("Reactive User");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Choose the Right Test Type
|
||||
|
||||
Select appropriate test annotations based on scope:
|
||||
|
||||
```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 { }
|
||||
```
|
||||
|
||||
### 2. Use @ServiceConnection for Container Management
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Keep Tests Deterministic
|
||||
|
||||
Always initialize test data explicitly:
|
||||
|
||||
```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();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 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);
|
||||
|
||||
// Avoid - JUnit assertions
|
||||
assertEquals("test@example.com", user.getEmail());
|
||||
assertTrue(users.size() == 3);
|
||||
```
|
||||
|
||||
### 5. 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 { }
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Context Caching Strategy
|
||||
|
||||
Maximize Spring context caching by grouping tests with similar configurations:
|
||||
|
||||
```java
|
||||
// Group repository tests with same configuration
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@TestContainerConfig
|
||||
@TestPropertySource(properties = "spring.datasource.url=jdbc:postgresql:testdb")
|
||||
public class UserRepositoryTest { }
|
||||
|
||||
// Group controller tests with same configuration
|
||||
@WebMvcTest(UserController.class)
|
||||
@AutoConfigureMockMvc
|
||||
public class UserControllerTest { }
|
||||
```
|
||||
|
||||
### Container Reuse Strategy
|
||||
|
||||
Reuse Testcontainers at JVM level for better performance:
|
||||
|
||||
```java
|
||||
@Testcontainers
|
||||
public class ContainerConfig {
|
||||
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();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Execution
|
||||
|
||||
### Maven Test Execution
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./mvnw test
|
||||
|
||||
# Run specific test class
|
||||
./mvnw test -Dtest=UserServiceTest
|
||||
|
||||
# Run integration tests only
|
||||
./mvnw test -Dintegration-test=true
|
||||
|
||||
# Run tests with coverage
|
||||
./mvnw clean jacoco:prepare-agent test jacoco:report
|
||||
```
|
||||
|
||||
### Gradle Test Execution
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./gradlew test
|
||||
|
||||
# Run specific test class
|
||||
./gradlew test --tests UserServiceTest
|
||||
|
||||
# Run integration tests only
|
||||
./gradlew integrationTest
|
||||
|
||||
# Run tests with coverage
|
||||
./gradlew test jacocoTestReport
|
||||
```
|
||||
|
||||
## CI/CD Configuration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Spring Boot Tests
|
||||
|
||||
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: 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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
For detailed information, refer to the following resources:
|
||||
|
||||
- [API Reference](./references/api-reference.md) - Complete test annotations and utilities
|
||||
- [Best Practices](./references/best-practices.md) - Testing patterns and optimization
|
||||
- [Workflow Patterns](./references/workflow-patterns.md) - Complete integration test examples
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **spring-boot-dependency-injection** - Unit testing patterns with constructor injection
|
||||
- **spring-boot-rest-api-standards** - REST API patterns to test
|
||||
- **spring-boot-crud-patterns** - CRUD patterns to test
|
||||
- **unit-test-service-layer** - Advanced service layer testing techniques
|
||||
|
||||
## Performance Targets
|
||||
|
||||
- **Unit tests**: < 50ms per test
|
||||
- **Slice tests**: < 100ms per test
|
||||
- **Integration tests**: < 500ms per test
|
||||
- **Maximize context caching** by grouping tests with same configuration
|
||||
- **Reuse Testcontainers** at JVM level where possible
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. Use test slices for focused, fast tests
|
||||
2. Prefer @ServiceConnection on Spring Boot 3.5+
|
||||
3. Keep tests deterministic with explicit setup
|
||||
4. Mock external dependencies, use real databases
|
||||
5. Avoid @DirtiesContext unless absolutely necessary
|
||||
6. Organize tests by layer to optimize context reuse
|
||||
|
||||
This skill enables building comprehensive test suites that validate Spring Boot applications reliably while maintaining fast feedback loops for development.
|
||||
@@ -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,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