Initial commit
This commit is contained in:
336
skills/junit-test/unit-test-application-events/SKILL.md
Normal file
336
skills/junit-test/unit-test-application-events/SKILL.md
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
name: unit-test-application-events
|
||||
description: Testing Spring application events (ApplicationEvent) with @EventListener and ApplicationEventPublisher. Test event publishing, listening, and async event handling in Spring Boot applications. Use when validating event-driven workflows in your Spring Boot services.
|
||||
category: testing
|
||||
tags: [junit-5, application-events, event-driven, listeners, publishers]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Application Events
|
||||
|
||||
Test Spring ApplicationEvent publishers and event listeners using JUnit 5. Verify event publishing, listener execution, and event propagation without full context startup.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing ApplicationEventPublisher event publishing
|
||||
- Testing @EventListener method invocation
|
||||
- Verifying event listener logic and side effects
|
||||
- Testing event propagation through listeners
|
||||
- Want fast event-driven architecture tests
|
||||
- Testing both synchronous and asynchronous event handling
|
||||
|
||||
## Setup: Event Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Event Publishing and Listening
|
||||
|
||||
### Custom Event and Publisher
|
||||
|
||||
```java
|
||||
// Custom application event
|
||||
public class UserCreatedEvent extends ApplicationEvent {
|
||||
private final User user;
|
||||
|
||||
public UserCreatedEvent(Object source, User user) {
|
||||
super(source);
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Service that publishes events
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserService(ApplicationEventPublisher eventPublisher, UserRepository userRepository) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public User createUser(String name, String email) {
|
||||
User user = new User(name, email);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
eventPublisher.publishEvent(new UserCreatedEvent(this, savedUser));
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceEventTest {
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldPublishUserCreatedEvent() {
|
||||
User newUser = new User(1L, "Alice", "alice@example.com");
|
||||
when(userRepository.save(any(User.class))).thenReturn(newUser);
|
||||
|
||||
ArgumentCaptor<UserCreatedEvent> eventCaptor = ArgumentCaptor.forClass(UserCreatedEvent.class);
|
||||
|
||||
userService.createUser("Alice", "alice@example.com");
|
||||
|
||||
verify(eventPublisher).publishEvent(eventCaptor.capture());
|
||||
|
||||
UserCreatedEvent capturedEvent = eventCaptor.getValue();
|
||||
assertThat(capturedEvent.getUser()).isEqualTo(newUser);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Event Listeners
|
||||
|
||||
### @EventListener Annotation
|
||||
|
||||
```java
|
||||
// Event listener
|
||||
@Component
|
||||
public class UserEventListener {
|
||||
|
||||
private final EmailService emailService;
|
||||
|
||||
public UserEventListener(EmailService emailService) {
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onUserCreated(UserCreatedEvent event) {
|
||||
User user = event.getUser();
|
||||
emailService.sendWelcomeEmail(user.getEmail());
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test for listener
|
||||
class UserEventListenerTest {
|
||||
|
||||
@Test
|
||||
void shouldSendWelcomeEmailWhenUserCreated() {
|
||||
EmailService emailService = mock(EmailService.class);
|
||||
UserEventListener listener = new UserEventListener(emailService);
|
||||
|
||||
User newUser = new User(1L, "Alice", "alice@example.com");
|
||||
UserCreatedEvent event = new UserCreatedEvent(this, newUser);
|
||||
|
||||
listener.onUserCreated(event);
|
||||
|
||||
verify(emailService).sendWelcomeEmail("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotThrowExceptionWhenEmailServiceFails() {
|
||||
EmailService emailService = mock(EmailService.class);
|
||||
doThrow(new RuntimeException("Email service down"))
|
||||
.when(emailService).sendWelcomeEmail(any());
|
||||
|
||||
UserEventListener listener = new UserEventListener(emailService);
|
||||
User newUser = new User(1L, "Alice", "alice@example.com");
|
||||
UserCreatedEvent event = new UserCreatedEvent(this, newUser);
|
||||
|
||||
// Should handle exception gracefully
|
||||
assertThatCode(() -> listener.onUserCreated(event))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Multiple Listeners
|
||||
|
||||
### Event Propagation
|
||||
|
||||
```java
|
||||
class UserCreatedEvent extends ApplicationEvent {
|
||||
private final User user;
|
||||
private final List<String> notifications = new ArrayList<>();
|
||||
|
||||
public UserCreatedEvent(Object source, User user) {
|
||||
super(source);
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public void addNotification(String notification) {
|
||||
notifications.add(notification);
|
||||
}
|
||||
|
||||
public List<String> getNotifications() {
|
||||
return notifications;
|
||||
}
|
||||
}
|
||||
|
||||
class MultiListenerTest {
|
||||
|
||||
@Test
|
||||
void shouldNotifyMultipleListenersSequentially() {
|
||||
EmailService emailService = mock(EmailService.class);
|
||||
NotificationService notificationService = mock(NotificationService.class);
|
||||
AnalyticsService analyticsService = mock(AnalyticsService.class);
|
||||
|
||||
UserEventListener emailListener = new UserEventListener(emailService);
|
||||
UserEventListener notificationListener = new UserEventListener(notificationService);
|
||||
UserEventListener analyticsListener = new UserEventListener(analyticsService);
|
||||
|
||||
User user = new User(1L, "Alice", "alice@example.com");
|
||||
UserCreatedEvent event = new UserCreatedEvent(this, user);
|
||||
|
||||
emailListener.onUserCreated(event);
|
||||
notificationListener.onUserCreated(event);
|
||||
analyticsListener.onUserCreated(event);
|
||||
|
||||
verify(emailService).send(any());
|
||||
verify(notificationService).notify(any());
|
||||
verify(analyticsService).track(any());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Conditional Event Listeners
|
||||
|
||||
### @EventListener with Condition
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class ConditionalEventListener {
|
||||
|
||||
@EventListener(condition = "#event.user.age > 18")
|
||||
public void onAdultUserCreated(UserCreatedEvent event) {
|
||||
// Handle adult user
|
||||
}
|
||||
}
|
||||
|
||||
class ConditionalListenerTest {
|
||||
|
||||
@Test
|
||||
void shouldProcessEventWhenConditionMatches() {
|
||||
// Test logic for matching condition
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipEventWhenConditionDoesNotMatch() {
|
||||
// Test logic for non-matching condition
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async Event Listeners
|
||||
|
||||
### @Async with @EventListener
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class AsyncEventListener {
|
||||
|
||||
private final SlowService slowService;
|
||||
|
||||
@EventListener
|
||||
@Async
|
||||
public void onUserCreatedAsync(UserCreatedEvent event) {
|
||||
slowService.processUser(event.getUser());
|
||||
}
|
||||
}
|
||||
|
||||
class AsyncEventListenerTest {
|
||||
|
||||
@Test
|
||||
void shouldProcessEventAsynchronously() throws Exception {
|
||||
SlowService slowService = mock(SlowService.class);
|
||||
AsyncEventListener listener = new AsyncEventListener(slowService);
|
||||
|
||||
User user = new User(1L, "Alice", "alice@example.com");
|
||||
UserCreatedEvent event = new UserCreatedEvent(this, user);
|
||||
|
||||
listener.onUserCreatedAsync(event);
|
||||
|
||||
// Event processed asynchronously
|
||||
Thread.sleep(100); // Wait for async completion
|
||||
verify(slowService).processUser(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Mock ApplicationEventPublisher** in unit tests
|
||||
- **Capture published events** using ArgumentCaptor
|
||||
- **Test listener side effects** explicitly
|
||||
- **Test error handling** in listeners
|
||||
- **Keep event listeners focused** on single responsibility
|
||||
- **Verify event data integrity** when capturing
|
||||
- **Test both sync and async** event processing
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Testing actual event publishing without mocking publisher
|
||||
- Not verifying listener invocation
|
||||
- Not capturing event details
|
||||
- Testing listener registration instead of logic
|
||||
- Not handling listener exceptions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Event not being captured**: Verify ArgumentCaptor type matches event class.
|
||||
|
||||
**Listener not invoked**: Ensure event is actually published and listener is registered.
|
||||
|
||||
**Async listener timing issues**: Use Thread.sleep() or Awaitility to wait for completion.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring ApplicationEvent](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/ApplicationEvent.html)
|
||||
- [Spring ApplicationEventPublisher](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/ApplicationEventPublisher.html)
|
||||
- [@EventListener Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/event/EventListener.html)
|
||||
476
skills/junit-test/unit-test-bean-validation/SKILL.md
Normal file
476
skills/junit-test/unit-test-bean-validation/SKILL.md
Normal file
@@ -0,0 +1,476 @@
|
||||
---
|
||||
name: unit-test-bean-validation
|
||||
description: Unit testing Jakarta Bean Validation (@Valid, @NotNull, @Min, @Max, etc.) with custom validators and constraint violations. Test validation logic without Spring context. Use when ensuring data integrity and validation rules are correct.
|
||||
category: testing
|
||||
tags: [junit-5, validation, bean-validation, jakarta-validation, constraints]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Bean Validation and Custom Validators
|
||||
|
||||
Test validation annotations and custom validator implementations using JUnit 5. Verify constraint violations, error messages, and validation logic in isolation.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing Jakarta Bean Validation (@NotNull, @Email, @Min, etc.)
|
||||
- Testing custom @Constraint validators
|
||||
- Verifying constraint violation error messages
|
||||
- Testing cross-field validation logic
|
||||
- Want fast validation tests without Spring context
|
||||
- Testing complex validation scenarios and edge cases
|
||||
|
||||
## Setup: Bean Validation
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("jakarta.validation:jakarta.validation-api")
|
||||
testImplementation("org.hibernate.validator:hibernate-validator")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Testing Validation Constraints
|
||||
|
||||
### Setup Validator
|
||||
|
||||
```java
|
||||
import jakarta.validation.Validator;
|
||||
import jakarta.validation.ValidatorFactory;
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class UserValidationTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||
validator = factory.getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassValidationWithValidUser() {
|
||||
User user = new User("Alice", "alice@example.com", 25);
|
||||
|
||||
Set<ConstraintViolation<User>> violations = validator.validate(user);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailValidationWhenNameIsNull() {
|
||||
User user = new User(null, "alice@example.com", 25);
|
||||
|
||||
Set<ConstraintViolation<User>> violations = validator.validate(user);
|
||||
|
||||
assertThat(violations)
|
||||
.hasSize(1)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("must not be blank");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Individual Constraint Annotations
|
||||
|
||||
### Test @NotNull, @NotBlank, @Email
|
||||
|
||||
```java
|
||||
class UserDtoTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenEmailIsInvalid() {
|
||||
UserDto dto = new UserDto("Alice", "invalid-email");
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getPropertyPath)
|
||||
.extracting(Path::toString)
|
||||
.contains("email");
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("must be a valid email address");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenNameIsBlank() {
|
||||
UserDto dto = new UserDto(" ", "alice@example.com");
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getPropertyPath)
|
||||
.extracting(Path::toString)
|
||||
.contains("name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenAgeIsNegative() {
|
||||
UserDto dto = new UserDto("Alice", "alice@example.com", -5);
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("must be greater than or equal to 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenAllConstraintsSatisfied() {
|
||||
UserDto dto = new UserDto("Alice", "alice@example.com", 25);
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(dto);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing @Min, @Max, @Size Constraints
|
||||
|
||||
```java
|
||||
class ProductDtoTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenPriceIsBelowMinimum() {
|
||||
ProductDto product = new ProductDto("Laptop", -100.0);
|
||||
|
||||
Set<ConstraintViolation<ProductDto>> violations = validator.validate(product);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("must be greater than 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenQuantityExceedsMaximum() {
|
||||
ProductDto product = new ProductDto("Laptop", 1000.0, 999999);
|
||||
|
||||
Set<ConstraintViolation<ProductDto>> violations = validator.validate(product);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("must be less than or equal to 10000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenDescriptionTooLong() {
|
||||
String longDescription = "x".repeat(1001);
|
||||
ProductDto product = new ProductDto("Laptop", 1000.0, longDescription);
|
||||
|
||||
Set<ConstraintViolation<ProductDto>> violations = validator.validate(product);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("size must be between 0 and 1000");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom Validators
|
||||
|
||||
### Create and Test Custom Constraint
|
||||
|
||||
```java
|
||||
// Custom constraint annotation
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Constraint(validatedBy = PhoneNumberValidator.class)
|
||||
public @interface ValidPhoneNumber {
|
||||
String message() default "invalid phone number format";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
||||
// Custom validator implementation
|
||||
public class PhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> {
|
||||
private static final String PHONE_PATTERN = "^\\d{3}-\\d{3}-\\d{4}$";
|
||||
|
||||
@Override
|
||||
public boolean isValid(String value, ConstraintValidatorContext context) {
|
||||
if (value == null) return true; // null values handled by @NotNull
|
||||
return value.matches(PHONE_PATTERN);
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test for custom validator
|
||||
class PhoneNumberValidatorTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptValidPhoneNumber() {
|
||||
Contact contact = new Contact("Alice", "555-123-4567");
|
||||
|
||||
Set<ConstraintViolation<Contact>> violations = validator.validate(contact);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidPhoneNumberFormat() {
|
||||
Contact contact = new Contact("Alice", "5551234567"); // No dashes
|
||||
|
||||
Set<ConstraintViolation<Contact>> violations = validator.validate(contact);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("invalid phone number format");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPhoneNumberWithLetters() {
|
||||
Contact contact = new Contact("Alice", "ABC-DEF-GHIJ");
|
||||
|
||||
Set<ConstraintViolation<Contact>> violations = validator.validate(contact);
|
||||
|
||||
assertThat(violations).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowNullPhoneNumber() {
|
||||
Contact contact = new Contact("Alice", null);
|
||||
|
||||
Set<ConstraintViolation<Contact>> violations = validator.validate(contact);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Cross-Field Validation
|
||||
|
||||
### Custom Multi-Field Constraint
|
||||
|
||||
```java
|
||||
// Custom constraint for cross-field validation
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Constraint(validatedBy = PasswordMatchValidator.class)
|
||||
public @interface PasswordsMatch {
|
||||
String message() default "passwords do not match";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
||||
// Validator implementation
|
||||
public class PasswordMatchValidator implements ConstraintValidator<PasswordsMatch, ChangePasswordRequest> {
|
||||
@Override
|
||||
public boolean isValid(ChangePasswordRequest value, ConstraintValidatorContext context) {
|
||||
if (value == null) return true;
|
||||
return value.getNewPassword().equals(value.getConfirmPassword());
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test
|
||||
class PasswordValidationTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassWhenPasswordsMatch() {
|
||||
ChangePasswordRequest request = new ChangePasswordRequest("oldPass", "newPass123", "newPass123");
|
||||
|
||||
Set<ConstraintViolation<ChangePasswordRequest>> violations = validator.validate(request);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenPasswordsDoNotMatch() {
|
||||
ChangePasswordRequest request = new ChangePasswordRequest("oldPass", "newPass123", "differentPass");
|
||||
|
||||
Set<ConstraintViolation<ChangePasswordRequest>> violations = validator.validate(request);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getMessage)
|
||||
.contains("passwords do not match");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Validation Groups
|
||||
|
||||
### Conditional Validation
|
||||
|
||||
```java
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public interface CreateValidation {}
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public interface UpdateValidation {}
|
||||
|
||||
class UserDto {
|
||||
@NotNull(groups = {CreateValidation.class})
|
||||
private String name;
|
||||
|
||||
@Min(value = 1, groups = {CreateValidation.class, UpdateValidation.class})
|
||||
private int age;
|
||||
}
|
||||
|
||||
class ValidationGroupsTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireNameOnlyDuringCreation() {
|
||||
UserDto user = new UserDto(null, 25);
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(user, CreateValidation.class);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(ConstraintViolation::getPropertyPath)
|
||||
.extracting(Path::toString)
|
||||
.contains("name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowNullNameDuringUpdate() {
|
||||
UserDto user = new UserDto(null, 25);
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(user, UpdateValidation.class);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Parameterized Validation Scenarios
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class EmailValidationTest {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"user@example.com",
|
||||
"john.doe+tag@example.co.uk",
|
||||
"admin123@subdomain.example.com"
|
||||
})
|
||||
void shouldAcceptValidEmails(String email) {
|
||||
UserDto user = new UserDto("Alice", email);
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(user);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"invalid-email",
|
||||
"user@",
|
||||
"@example.com",
|
||||
"user name@example.com"
|
||||
})
|
||||
void shouldRejectInvalidEmails(String email) {
|
||||
UserDto user = new UserDto("Alice", email);
|
||||
|
||||
Set<ConstraintViolation<UserDto>> violations = validator.validate(user);
|
||||
|
||||
assertThat(violations).isNotEmpty();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Validate at unit test level** before testing service/controller layers
|
||||
- **Test both valid and invalid cases** for every constraint
|
||||
- **Use custom validators** for business-specific validation rules
|
||||
- **Test error messages** to ensure they're user-friendly
|
||||
- **Test edge cases**: null, empty string, whitespace-only strings
|
||||
- **Use validation groups** for conditional validation rules
|
||||
- **Keep validator logic simple** - complex validation belongs in service tests
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to test null values
|
||||
- Not extracting violation details (message, property, constraint type)
|
||||
- Testing validation at service/controller level instead of unit tests
|
||||
- Creating overly complex custom validators
|
||||
- Not documenting constraint purposes in error messages
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**ValidatorFactory not found**: Ensure `jakarta.validation-api` and `hibernate-validator` are on classpath.
|
||||
|
||||
**Custom validator not invoked**: Verify `@Constraint(validatedBy = YourValidator.class)` is correctly specified.
|
||||
|
||||
**Null handling confusion**: By default, `@NotNull` checks null, other constraints ignore null (use `@NotNull` with others for mandatory fields).
|
||||
|
||||
## References
|
||||
|
||||
- [Jakarta Bean Validation Spec](https://jakarta.ee/specifications/bean-validation/)
|
||||
- [Hibernate Validator Documentation](https://hibernate.org/validator/)
|
||||
- [Custom Constraints](https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#validator-customconstraints)
|
||||
453
skills/junit-test/unit-test-boundary-conditions/SKILL.md
Normal file
453
skills/junit-test/unit-test-boundary-conditions/SKILL.md
Normal file
@@ -0,0 +1,453 @@
|
||||
---
|
||||
name: unit-test-boundary-conditions
|
||||
description: Edge case and boundary testing patterns for unit tests. Testing minimum/maximum values, null cases, empty collections, and numeric precision. Pure JUnit 5 unit tests. Use when ensuring code handles limits and special cases correctly.
|
||||
category: testing
|
||||
tags: [junit-5, boundary-testing, edge-cases, parameterized-test]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Boundary Conditions and Edge Cases
|
||||
|
||||
Test boundary conditions, edge cases, and limit values systematically. Verify code behavior at limits, with null/empty inputs, and overflow scenarios.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing minimum and maximum values
|
||||
- Testing null and empty inputs
|
||||
- Testing whitespace-only strings
|
||||
- Testing overflow/underflow scenarios
|
||||
- Testing collections with zero/one/many items
|
||||
- Verifying behavior at API boundaries
|
||||
- Want comprehensive edge case coverage
|
||||
|
||||
## Setup: Boundary Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-params")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Numeric Boundary Testing
|
||||
|
||||
### Integer Limits
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class IntegerBoundaryTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {Integer.MIN_VALUE, Integer.MIN_VALUE + 1, 0, Integer.MAX_VALUE - 1, Integer.MAX_VALUE})
|
||||
void shouldHandleIntegerBoundaries(int value) {
|
||||
assertThat(value).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleIntegerOverflow() {
|
||||
int maxInt = Integer.MAX_VALUE;
|
||||
int result = Math.addExact(maxInt, 1); // Will throw ArithmeticException
|
||||
|
||||
assertThatThrownBy(() -> Math.addExact(Integer.MAX_VALUE, 1))
|
||||
.isInstanceOf(ArithmeticException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleIntegerUnderflow() {
|
||||
assertThatThrownBy(() -> Math.subtractExact(Integer.MIN_VALUE, 1))
|
||||
.isInstanceOf(ArithmeticException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleZero() {
|
||||
int result = MathUtils.divide(0, 5);
|
||||
assertThat(result).isZero();
|
||||
|
||||
assertThatThrownBy(() -> MathUtils.divide(5, 0))
|
||||
.isInstanceOf(ArithmeticException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## String Boundary Testing
|
||||
|
||||
### Null, Empty, and Whitespace
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class StringBoundaryTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", " ", " ", "\t", "\n"})
|
||||
void shouldConsiderEmptyAndWhitespaceAsInvalid(String input) {
|
||||
boolean result = StringUtils.isNotBlank(input);
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullString() {
|
||||
String result = StringUtils.trim(null);
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleSingleCharacter() {
|
||||
String result = StringUtils.capitalize("a");
|
||||
assertThat(result).isEqualTo("A");
|
||||
|
||||
String result2 = StringUtils.trim("x");
|
||||
assertThat(result2).isEqualTo("x");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleVeryLongString() {
|
||||
String longString = "x".repeat(1000000);
|
||||
|
||||
assertThat(longString.length()).isEqualTo(1000000);
|
||||
assertThat(StringUtils.isNotBlank(longString)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleSpecialCharacters() {
|
||||
String special = "!@#$%^&*()_+-={}[]|\\:;<>?,./";
|
||||
|
||||
assertThat(StringUtils.length(special)).isEqualTo(31);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Collection Boundary Testing
|
||||
|
||||
### Empty, Single, and Large Collections
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class CollectionBoundaryTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyList() {
|
||||
List<String> empty = List.of();
|
||||
|
||||
assertThat(empty).isEmpty();
|
||||
assertThat(CollectionUtils.first(empty)).isNull();
|
||||
assertThat(CollectionUtils.count(empty)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleSingleElementList() {
|
||||
List<String> single = List.of("only");
|
||||
|
||||
assertThat(single).hasSize(1);
|
||||
assertThat(CollectionUtils.first(single)).isEqualTo("only");
|
||||
assertThat(CollectionUtils.last(single)).isEqualTo("only");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleLargeList() {
|
||||
List<Integer> large = new ArrayList<>();
|
||||
for (int i = 0; i < 100000; i++) {
|
||||
large.add(i);
|
||||
}
|
||||
|
||||
assertThat(large).hasSize(100000);
|
||||
assertThat(CollectionUtils.first(large)).isZero();
|
||||
assertThat(CollectionUtils.last(large)).isEqualTo(99999);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullInCollection() {
|
||||
List<String> withNull = new ArrayList<>(List.of("a", null, "c"));
|
||||
|
||||
assertThat(withNull).contains(null);
|
||||
assertThat(CollectionUtils.filterNonNull(withNull)).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleDuplicatesInCollection() {
|
||||
List<Integer> duplicates = List.of(1, 1, 2, 2, 3, 3);
|
||||
|
||||
assertThat(duplicates).hasSize(6);
|
||||
Set<Integer> unique = new HashSet<>(duplicates);
|
||||
assertThat(unique).hasSize(3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Floating Point Boundary Testing
|
||||
|
||||
### Precision and Special Values
|
||||
|
||||
```java
|
||||
class FloatingPointBoundaryTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleFloatingPointPrecision() {
|
||||
double result = 0.1 + 0.2;
|
||||
|
||||
// Floating point comparison needs tolerance
|
||||
assertThat(result).isCloseTo(0.3, within(0.0001));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleSpecialFloatingPointValues() {
|
||||
assertThat(Double.POSITIVE_INFINITY).isGreaterThan(Double.MAX_VALUE);
|
||||
assertThat(Double.NEGATIVE_INFINITY).isLessThan(Double.MIN_VALUE);
|
||||
assertThat(Double.NaN).isNotEqualTo(Double.NaN); // NaN != NaN
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleVerySmallAndLargeNumbers() {
|
||||
double tiny = Double.MIN_VALUE;
|
||||
double huge = Double.MAX_VALUE;
|
||||
|
||||
assertThat(tiny).isGreaterThan(0);
|
||||
assertThat(huge).isPositive();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleZeroInDivision() {
|
||||
double result = 1.0 / 0.0;
|
||||
|
||||
assertThat(result).isEqualTo(Double.POSITIVE_INFINITY);
|
||||
|
||||
double result2 = -1.0 / 0.0;
|
||||
assertThat(result2).isEqualTo(Double.NEGATIVE_INFINITY);
|
||||
|
||||
double result3 = 0.0 / 0.0;
|
||||
assertThat(result3).isNaN();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Date/Time Boundary Testing
|
||||
|
||||
### Min/Max Dates and Edge Cases
|
||||
|
||||
```java
|
||||
class DateTimeBoundaryTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleMinAndMaxDates() {
|
||||
LocalDate min = LocalDate.MIN;
|
||||
LocalDate max = LocalDate.MAX;
|
||||
|
||||
assertThat(min).isBefore(max);
|
||||
assertThat(DateUtils.isValid(min)).isTrue();
|
||||
assertThat(DateUtils.isValid(max)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleLeapYearBoundary() {
|
||||
LocalDate leapYearEnd = LocalDate.of(2024, 2, 29);
|
||||
|
||||
assertThat(leapYearEnd).isNotNull();
|
||||
assertThat(LocalDate.of(2024, 2, 29)).isEqualTo(leapYearEnd);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleInvalidDateInNonLeapYear() {
|
||||
assertThatThrownBy(() -> LocalDate.of(2023, 2, 29))
|
||||
.isInstanceOf(DateTimeException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleYearBoundaries() {
|
||||
LocalDate newYear = LocalDate.of(2024, 1, 1);
|
||||
LocalDate lastDay = LocalDate.of(2024, 12, 31);
|
||||
|
||||
assertThat(newYear).isBefore(lastDay);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleMidnightBoundary() {
|
||||
LocalTime midnight = LocalTime.MIDNIGHT;
|
||||
LocalTime almostMidnight = LocalTime.of(23, 59, 59);
|
||||
|
||||
assertThat(almostMidnight).isBefore(midnight);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Array Index Boundary Testing
|
||||
|
||||
### First, Last, and Out of Bounds
|
||||
|
||||
```java
|
||||
class ArrayBoundaryTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleFirstElementAccess() {
|
||||
int[] array = {1, 2, 3, 4, 5};
|
||||
|
||||
assertThat(array[0]).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleLastElementAccess() {
|
||||
int[] array = {1, 2, 3, 4, 5};
|
||||
|
||||
assertThat(array[array.length - 1]).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowOnNegativeIndex() {
|
||||
int[] array = {1, 2, 3};
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
int value = array[-1];
|
||||
}).isInstanceOf(ArrayIndexOutOfBoundsException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowOnOutOfBoundsIndex() {
|
||||
int[] array = {1, 2, 3};
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
int value = array[10];
|
||||
}).isInstanceOf(ArrayIndexOutOfBoundsException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyArray() {
|
||||
int[] empty = {};
|
||||
|
||||
assertThat(empty.length).isZero();
|
||||
assertThatThrownBy(() -> {
|
||||
int value = empty[0];
|
||||
}).isInstanceOf(ArrayIndexOutOfBoundsException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Concurrent and Thread Boundary Testing
|
||||
|
||||
### Null and Race Conditions
|
||||
|
||||
```java
|
||||
import java.util.concurrent.*;
|
||||
|
||||
class ConcurrentBoundaryTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleNullInConcurrentMap() {
|
||||
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
|
||||
|
||||
map.put("key", "value");
|
||||
assertThat(map.get("nonexistent")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleConcurrentModification() {
|
||||
List<Integer> list = new CopyOnWriteArrayList<>(List.of(1, 2, 3, 4, 5));
|
||||
|
||||
// Should not throw ConcurrentModificationException
|
||||
for (int num : list) {
|
||||
if (num == 3) {
|
||||
list.add(6);
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(list).hasSize(6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyBlockingQueue() throws InterruptedException {
|
||||
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
|
||||
|
||||
assertThat(queue.poll()).isNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Parameterized Boundary Testing
|
||||
|
||||
### Multiple Boundary Cases
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
class ParameterizedBoundaryTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"null, false", // null
|
||||
"'', false", // empty
|
||||
"' ', false", // whitespace
|
||||
"a, true", // single char
|
||||
"abc, true" // normal
|
||||
})
|
||||
void shouldValidateStringBoundaries(String input, boolean expected) {
|
||||
boolean result = StringValidator.isValid(input);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {Integer.MIN_VALUE, 0, 1, -1, Integer.MAX_VALUE})
|
||||
void shouldHandleNumericBoundaries(int value) {
|
||||
assertThat(value).isNotNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Test explicitly at boundaries** - don't rely on random testing
|
||||
- **Test null and empty separately** from valid inputs
|
||||
- **Use parameterized tests** for multiple boundary cases
|
||||
- **Test both sides of boundaries** (just below, at, just above)
|
||||
- **Verify error messages** are helpful for invalid boundaries
|
||||
- **Document why** specific boundaries matter
|
||||
- **Test overflow/underflow** for numeric operations
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Testing only "happy path" without boundary cases
|
||||
- Forgetting null/empty cases
|
||||
- Not testing floating point precision
|
||||
- Not testing collection boundaries (empty, single, many)
|
||||
- Not testing string boundaries (null, empty, whitespace)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Floating point comparison fails**: Use `isCloseTo(expected, within(tolerance))`.
|
||||
|
||||
**Collection boundaries unclear**: List cases explicitly: empty (0), single (1), many (>1).
|
||||
|
||||
**Date boundary confusing**: Use `LocalDate.MIN`, `LocalDate.MAX` for clear boundaries.
|
||||
|
||||
## References
|
||||
|
||||
- [Integer.MIN_VALUE/MAX_VALUE](https://docs.oracle.com/javase/8/docs/api/java/lang/Integer.html)
|
||||
- [Double.MIN_VALUE/MAX_VALUE](https://docs.oracle.com/javase/8/docs/api/java/lang/Double.html)
|
||||
- [AssertJ Floating Point Assertions](https://assertj.github.io/assertj-core-features-highlight.html#assertions-on-numbers)
|
||||
- [Boundary Value Analysis](https://en.wikipedia.org/wiki/Boundary-value_analysis)
|
||||
401
skills/junit-test/unit-test-caching/SKILL.md
Normal file
401
skills/junit-test/unit-test-caching/SKILL.md
Normal file
@@ -0,0 +1,401 @@
|
||||
---
|
||||
name: unit-test-caching
|
||||
description: Unit tests for caching behavior using Spring Cache annotations (@Cacheable, @CachePut, @CacheEvict). Use when validating cache configuration and cache hit/miss scenarios.
|
||||
category: testing
|
||||
tags: [junit-5, caching, cacheable, cache-evict, cache-put]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Spring Caching
|
||||
|
||||
Test Spring caching annotations (@Cacheable, @CacheEvict, @CachePut) without full Spring context. Verify cache behavior, hits/misses, and invalidation strategies.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing @Cacheable method caching
|
||||
- Testing @CacheEvict cache invalidation
|
||||
- Testing @CachePut cache updates
|
||||
- Verifying cache key generation
|
||||
- Testing conditional caching
|
||||
- Want fast caching tests without Redis or cache infrastructure
|
||||
|
||||
## Setup: Caching Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-cache")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.mockito:mockito-core")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Testing @Cacheable
|
||||
|
||||
### Cache Hit and Miss Behavior
|
||||
|
||||
```java
|
||||
// Service with caching
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Cacheable("users")
|
||||
public User getUserById(Long id) {
|
||||
return userRepository.findById(id).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Test caching behavior
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
class CacheTestConfig {
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
return new ConcurrentMapCacheManager("users");
|
||||
}
|
||||
}
|
||||
|
||||
class UserServiceCachingTest {
|
||||
|
||||
private UserRepository userRepository;
|
||||
private UserService userService;
|
||||
private CacheManager cacheManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = mock(UserRepository.class);
|
||||
cacheManager = new ConcurrentMapCacheManager("users");
|
||||
userService = new UserService(userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCacheUserAfterFirstCall() {
|
||||
User user = new User(1L, "Alice");
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
User firstCall = userService.getUserById(1L);
|
||||
User secondCall = userService.getUserById(1L);
|
||||
|
||||
assertThat(firstCall).isEqualTo(secondCall);
|
||||
verify(userRepository, times(1)).findById(1L); // Called only once due to cache
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnCachedValueOnSecondCall() {
|
||||
User user = new User(1L, "Alice");
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.getUserById(1L); // First call - hits database
|
||||
User cachedResult = userService.getUserById(1L); // Second call - hits cache
|
||||
|
||||
assertThat(cachedResult).isEqualTo(user);
|
||||
verify(userRepository, times(1)).findById(1L);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing @CacheEvict
|
||||
|
||||
### Cache Invalidation
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ProductService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
public ProductService(ProductRepository productRepository) {
|
||||
this.productRepository = productRepository;
|
||||
}
|
||||
|
||||
@Cacheable("products")
|
||||
public Product getProductById(Long id) {
|
||||
return productRepository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
@CacheEvict("products")
|
||||
public void deleteProduct(Long id) {
|
||||
productRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "products", allEntries = true)
|
||||
public void clearAllProducts() {
|
||||
// Clear entire cache
|
||||
}
|
||||
}
|
||||
|
||||
class ProductCacheEvictTest {
|
||||
|
||||
private ProductRepository productRepository;
|
||||
private ProductService productService;
|
||||
private CacheManager cacheManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
productRepository = mock(ProductRepository.class);
|
||||
cacheManager = new ConcurrentMapCacheManager("products");
|
||||
productService = new ProductService(productRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvictProductFromCacheWhenDeleted() {
|
||||
Product product = new Product(1L, "Laptop", 999.99);
|
||||
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
|
||||
|
||||
productService.getProductById(1L); // Cache the product
|
||||
|
||||
productService.deleteProduct(1L); // Evict from cache
|
||||
|
||||
User cachedAfterEvict = userService.getUserById(1L);
|
||||
|
||||
// After eviction, repository should be called again
|
||||
verify(productRepository, times(2)).findById(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClearAllEntriesFromCache() {
|
||||
Product product1 = new Product(1L, "Laptop", 999.99);
|
||||
Product product2 = new Product(2L, "Mouse", 29.99);
|
||||
when(productRepository.findById(1L)).thenReturn(Optional.of(product1));
|
||||
when(productRepository.findById(2L)).thenReturn(Optional.of(product2));
|
||||
|
||||
productService.getProductById(1L);
|
||||
productService.getProductById(2L);
|
||||
|
||||
productService.clearAllProducts(); // Clear all cache entries
|
||||
|
||||
productService.getProductById(1L);
|
||||
productService.getProductById(2L);
|
||||
|
||||
// Repository called twice for each product
|
||||
verify(productRepository, times(2)).findById(1L);
|
||||
verify(productRepository, times(2)).findById(2L);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing @CachePut
|
||||
|
||||
### Cache Update
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
|
||||
public OrderService(OrderRepository orderRepository) {
|
||||
this.orderRepository = orderRepository;
|
||||
}
|
||||
|
||||
@Cacheable("orders")
|
||||
public Order getOrder(Long id) {
|
||||
return orderRepository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
@CachePut(value = "orders", key = "#order.id")
|
||||
public Order updateOrder(Order order) {
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
}
|
||||
|
||||
class OrderCachePutTest {
|
||||
|
||||
private OrderRepository orderRepository;
|
||||
private OrderService orderService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
orderRepository = mock(OrderRepository.class);
|
||||
orderService = new OrderService(orderRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdateCacheWhenOrderIsUpdated() {
|
||||
Order originalOrder = new Order(1L, "Pending", 100.0);
|
||||
Order updatedOrder = new Order(1L, "Shipped", 100.0);
|
||||
|
||||
when(orderRepository.findById(1L)).thenReturn(Optional.of(originalOrder));
|
||||
when(orderRepository.save(updatedOrder)).thenReturn(updatedOrder);
|
||||
|
||||
orderService.getOrder(1L);
|
||||
Order result = orderService.updateOrder(updatedOrder);
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo("Shipped");
|
||||
|
||||
// Next call should return updated version from cache
|
||||
Order cachedOrder = orderService.getOrder(1L);
|
||||
assertThat(cachedOrder.getStatus()).isEqualTo("Shipped");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Conditional Caching
|
||||
|
||||
### Cache with Conditions
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class DataService {
|
||||
|
||||
private final DataRepository dataRepository;
|
||||
|
||||
public DataService(DataRepository dataRepository) {
|
||||
this.dataRepository = dataRepository;
|
||||
}
|
||||
|
||||
@Cacheable(value = "data", unless = "#result == null")
|
||||
public Data getData(Long id) {
|
||||
return dataRepository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
@Cacheable(value = "users", condition = "#id > 0")
|
||||
public User getUser(Long id) {
|
||||
return userRepository.findById(id).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
class ConditionalCachingTest {
|
||||
|
||||
@Test
|
||||
void shouldNotCacheNullResults() {
|
||||
DataRepository dataRepository = mock(DataRepository.class);
|
||||
when(dataRepository.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
DataService service = new DataService(dataRepository);
|
||||
|
||||
service.getData(999L);
|
||||
service.getData(999L);
|
||||
|
||||
// Should call repository twice because null results are not cached
|
||||
verify(dataRepository, times(2)).findById(999L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCacheWhenConditionIsFalse() {
|
||||
UserRepository userRepository = mock(UserRepository.class);
|
||||
User user = new User(1L, "Alice");
|
||||
when(userRepository.findById(-1L)).thenReturn(Optional.of(user));
|
||||
|
||||
DataService service = new DataService(null);
|
||||
|
||||
service.getUser(-1L);
|
||||
service.getUser(-1L);
|
||||
|
||||
// Should call repository twice because id <= 0 doesn't match condition
|
||||
verify(userRepository, times(2)).findById(-1L);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Cache Keys
|
||||
|
||||
### Verify Cache Key Generation
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class InventoryService {
|
||||
|
||||
private final InventoryRepository inventoryRepository;
|
||||
|
||||
public InventoryService(InventoryRepository inventoryRepository) {
|
||||
this.inventoryRepository = inventoryRepository;
|
||||
}
|
||||
|
||||
@Cacheable(value = "inventory", key = "#productId + '-' + #warehouseId")
|
||||
public InventoryItem getInventory(Long productId, Long warehouseId) {
|
||||
return inventoryRepository.findByProductAndWarehouse(productId, warehouseId);
|
||||
}
|
||||
}
|
||||
|
||||
class CacheKeyTest {
|
||||
|
||||
@Test
|
||||
void shouldGenerateCorrectCacheKey() {
|
||||
InventoryRepository repository = mock(InventoryRepository.class);
|
||||
InventoryItem item = new InventoryItem(1L, 1L, 100);
|
||||
when(repository.findByProductAndWarehouse(1L, 1L)).thenReturn(item);
|
||||
|
||||
InventoryService service = new InventoryService(repository);
|
||||
|
||||
service.getInventory(1L, 1L); // Cache: "1-1"
|
||||
service.getInventory(1L, 1L); // Hit cache: "1-1"
|
||||
service.getInventory(2L, 1L); // Miss cache: "2-1"
|
||||
|
||||
verify(repository, times(2)).findByProductAndWarehouse(any(), any());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use in-memory CacheManager** for unit tests
|
||||
- **Verify repository calls** to confirm cache hits/misses
|
||||
- **Test both positive and negative** cache scenarios
|
||||
- **Test cache invalidation** thoroughly
|
||||
- **Test conditional caching** with various conditions
|
||||
- **Keep cache configuration simple** in tests
|
||||
- **Mock dependencies** that services use
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Testing actual cache infrastructure instead of caching logic
|
||||
- Not verifying repository call counts
|
||||
- Forgetting to test cache eviction
|
||||
- Not testing conditional caching
|
||||
- Not resetting cache between tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Cache not working in tests**: Ensure `@EnableCaching` is in test configuration.
|
||||
|
||||
**Wrong cache key generated**: Use `SpEL` syntax correctly in `@Cacheable(key = "...")`.
|
||||
|
||||
**Cache not evicting**: Verify `@CacheEvict` key matches stored key exactly.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring Caching Documentation](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache)
|
||||
- [Spring Cache Abstractions](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)
|
||||
- [SpEL in Caching](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#expressions)
|
||||
458
skills/junit-test/unit-test-config-properties/SKILL.md
Normal file
458
skills/junit-test/unit-test-config-properties/SKILL.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
name: unit-test-config-properties
|
||||
description: Unit tests for @ConfigurationProperties classes with @ConfigurationPropertiesTest. Use when validating application configuration binding and validation.
|
||||
category: testing
|
||||
tags: [junit-5, configuration-properties, spring-profiles, property-binding]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Configuration Properties and Profiles
|
||||
|
||||
Test @ConfigurationProperties bindings, environment-specific configurations, and property validation using JUnit 5. Verify configuration loading without full Spring context startup.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing @ConfigurationProperties property binding
|
||||
- Testing property name mapping and type conversions
|
||||
- Verifying configuration validation
|
||||
- Testing environment-specific configurations
|
||||
- Testing nested property structures
|
||||
- Want fast configuration tests without Spring context
|
||||
|
||||
## Setup: Configuration Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Testing ConfigurationProperties
|
||||
|
||||
### Simple Property Binding
|
||||
|
||||
```java
|
||||
// Configuration properties class
|
||||
@ConfigurationProperties(prefix = "app.security")
|
||||
@Data
|
||||
public class SecurityProperties {
|
||||
private String jwtSecret;
|
||||
private long jwtExpirationMs;
|
||||
private int maxLoginAttempts;
|
||||
private boolean enableTwoFactor;
|
||||
}
|
||||
|
||||
// Unit test
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class SecurityPropertiesTest {
|
||||
|
||||
@Test
|
||||
void shouldBindPropertiesFromEnvironment() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.security.jwtSecret=my-secret-key",
|
||||
"app.security.jwtExpirationMs=3600000",
|
||||
"app.security.maxLoginAttempts=5",
|
||||
"app.security.enableTwoFactor=true"
|
||||
)
|
||||
.withBean(SecurityProperties.class)
|
||||
.run(context -> {
|
||||
SecurityProperties props = context.getBean(SecurityProperties.class);
|
||||
|
||||
assertThat(props.getJwtSecret()).isEqualTo("my-secret-key");
|
||||
assertThat(props.getJwtExpirationMs()).isEqualTo(3600000L);
|
||||
assertThat(props.getMaxLoginAttempts()).isEqualTo(5);
|
||||
assertThat(props.isEnableTwoFactor()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultValuesWhenPropertiesNotProvided() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("app.security.jwtSecret=key")
|
||||
.withBean(SecurityProperties.class)
|
||||
.run(context -> {
|
||||
SecurityProperties props = context.getBean(SecurityProperties.class);
|
||||
|
||||
assertThat(props.getJwtSecret()).isEqualTo("key");
|
||||
assertThat(props.getMaxLoginAttempts()).isZero();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Nested Configuration Properties
|
||||
|
||||
### Complex Property Structure
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "app.database")
|
||||
@Data
|
||||
public class DatabaseProperties {
|
||||
private String url;
|
||||
private String username;
|
||||
private Pool pool = new Pool();
|
||||
private List<Replica> replicas = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class Pool {
|
||||
private int maxSize = 10;
|
||||
private int minIdle = 5;
|
||||
private long connectionTimeout = 30000;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Replica {
|
||||
private String name;
|
||||
private String url;
|
||||
private int priority;
|
||||
}
|
||||
}
|
||||
|
||||
class NestedPropertiesTest {
|
||||
|
||||
@Test
|
||||
void shouldBindNestedProperties() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.database.url=jdbc:mysql://localhost/db",
|
||||
"app.database.username=admin",
|
||||
"app.database.pool.maxSize=20",
|
||||
"app.database.pool.minIdle=10",
|
||||
"app.database.pool.connectionTimeout=60000"
|
||||
)
|
||||
.withBean(DatabaseProperties.class)
|
||||
.run(context -> {
|
||||
DatabaseProperties props = context.getBean(DatabaseProperties.class);
|
||||
|
||||
assertThat(props.getUrl()).isEqualTo("jdbc:mysql://localhost/db");
|
||||
assertThat(props.getPool().getMaxSize()).isEqualTo(20);
|
||||
assertThat(props.getPool().getConnectionTimeout()).isEqualTo(60000L);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBindListOfReplicas() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.database.replicas[0].name=replica-1",
|
||||
"app.database.replicas[0].url=jdbc:mysql://replica1/db",
|
||||
"app.database.replicas[0].priority=1",
|
||||
"app.database.replicas[1].name=replica-2",
|
||||
"app.database.replicas[1].url=jdbc:mysql://replica2/db",
|
||||
"app.database.replicas[1].priority=2"
|
||||
)
|
||||
.withBean(DatabaseProperties.class)
|
||||
.run(context -> {
|
||||
DatabaseProperties props = context.getBean(DatabaseProperties.class);
|
||||
|
||||
assertThat(props.getReplicas()).hasSize(2);
|
||||
assertThat(props.getReplicas().get(0).getName()).isEqualTo("replica-1");
|
||||
assertThat(props.getReplicas().get(1).getPriority()).isEqualTo(2);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Property Validation
|
||||
|
||||
### Validate Configuration with Constraints
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "app.server")
|
||||
@Data
|
||||
@Validated
|
||||
public class ServerProperties {
|
||||
@NotBlank
|
||||
private String host;
|
||||
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
private int port = 8080;
|
||||
|
||||
@Positive
|
||||
private int threadPoolSize;
|
||||
|
||||
@Email
|
||||
private String adminEmail;
|
||||
}
|
||||
|
||||
class ConfigurationValidationTest {
|
||||
|
||||
@Test
|
||||
void shouldFailValidationWhenHostIsBlank() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.server.host=",
|
||||
"app.server.port=8080",
|
||||
"app.server.threadPoolSize=10"
|
||||
)
|
||||
.withBean(ServerProperties.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed()
|
||||
.getFailure()
|
||||
.hasMessageContaining("host");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailValidationWhenPortOutOfRange() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.server.host=localhost",
|
||||
"app.server.port=99999",
|
||||
"app.server.threadPoolSize=10"
|
||||
)
|
||||
.withBean(ServerProperties.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassValidationWithValidConfiguration() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.server.host=localhost",
|
||||
"app.server.port=8080",
|
||||
"app.server.threadPoolSize=10",
|
||||
"app.server.adminEmail=admin@example.com"
|
||||
)
|
||||
.withBean(ServerProperties.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
ServerProperties props = context.getBean(ServerProperties.class);
|
||||
assertThat(props.getHost()).isEqualTo("localhost");
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Profile-Specific Configurations
|
||||
|
||||
### Environment-Specific Properties
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@Profile("prod")
|
||||
class ProductionConfiguration {
|
||||
@Bean
|
||||
public SecurityProperties securityProperties() {
|
||||
SecurityProperties props = new SecurityProperties();
|
||||
props.setEnableTwoFactor(true);
|
||||
props.setMaxLoginAttempts(3);
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@Profile("dev")
|
||||
class DevelopmentConfiguration {
|
||||
@Bean
|
||||
public SecurityProperties securityProperties() {
|
||||
SecurityProperties props = new SecurityProperties();
|
||||
props.setEnableTwoFactor(false);
|
||||
props.setMaxLoginAttempts(999);
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileBasedConfigurationTest {
|
||||
|
||||
@Test
|
||||
void shouldLoadProductionConfiguration() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.profiles.active=prod")
|
||||
.withUserConfiguration(ProductionConfiguration.class)
|
||||
.run(context -> {
|
||||
SecurityProperties props = context.getBean(SecurityProperties.class);
|
||||
|
||||
assertThat(props.isEnableTwoFactor()).isTrue();
|
||||
assertThat(props.getMaxLoginAttempts()).isEqualTo(3);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLoadDevelopmentConfiguration() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.profiles.active=dev")
|
||||
.withUserConfiguration(DevelopmentConfiguration.class)
|
||||
.run(context -> {
|
||||
SecurityProperties props = context.getBean(SecurityProperties.class);
|
||||
|
||||
assertThat(props.isEnableTwoFactor()).isFalse();
|
||||
assertThat(props.getMaxLoginAttempts()).isEqualTo(999);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Type Conversion
|
||||
|
||||
### Property Type Binding
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "app.features")
|
||||
@Data
|
||||
public class FeatureProperties {
|
||||
private Duration cacheExpiry = Duration.ofMinutes(10);
|
||||
private DataSize maxUploadSize = DataSize.ofMegabytes(100);
|
||||
private List<String> enabledFeatures;
|
||||
private Map<String, String> featureFlags;
|
||||
private Charset fileEncoding = StandardCharsets.UTF_8;
|
||||
}
|
||||
|
||||
class TypeConversionTest {
|
||||
|
||||
@Test
|
||||
void shouldConvertStringToDuration() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("app.features.cacheExpiry=30s")
|
||||
.withBean(FeatureProperties.class)
|
||||
.run(context -> {
|
||||
FeatureProperties props = context.getBean(FeatureProperties.class);
|
||||
|
||||
assertThat(props.getCacheExpiry()).isEqualTo(Duration.ofSeconds(30));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConvertStringToDataSize() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("app.features.maxUploadSize=50MB")
|
||||
.withBean(FeatureProperties.class)
|
||||
.run(context -> {
|
||||
FeatureProperties props = context.getBean(FeatureProperties.class);
|
||||
|
||||
assertThat(props.getMaxUploadSize()).isEqualTo(DataSize.ofMegabytes(50));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConvertCommaDelimitedListToList() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("app.features.enabledFeatures=feature1,feature2,feature3")
|
||||
.withBean(FeatureProperties.class)
|
||||
.run(context -> {
|
||||
FeatureProperties props = context.getBean(FeatureProperties.class);
|
||||
|
||||
assertThat(props.getEnabledFeatures())
|
||||
.containsExactly("feature1", "feature2", "feature3");
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Property Binding with Default Values
|
||||
|
||||
### Verify Default Configuration
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "app.cache")
|
||||
@Data
|
||||
public class CacheProperties {
|
||||
private long ttlSeconds = 300;
|
||||
private int maxSize = 1000;
|
||||
private boolean enabled = true;
|
||||
private String cacheType = "IN_MEMORY";
|
||||
}
|
||||
|
||||
class DefaultValuesTest {
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultValuesWhenNotSpecified() {
|
||||
new ApplicationContextRunner()
|
||||
.withBean(CacheProperties.class)
|
||||
.run(context -> {
|
||||
CacheProperties props = context.getBean(CacheProperties.class);
|
||||
|
||||
assertThat(props.getTtlSeconds()).isEqualTo(300L);
|
||||
assertThat(props.getMaxSize()).isEqualTo(1000);
|
||||
assertThat(props.isEnabled()).isTrue();
|
||||
assertThat(props.getCacheType()).isEqualTo("IN_MEMORY");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldOverrideDefaultValuesWithProvidedProperties() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues(
|
||||
"app.cache.ttlSeconds=600",
|
||||
"app.cache.cacheType=REDIS"
|
||||
)
|
||||
.withBean(CacheProperties.class)
|
||||
.run(context -> {
|
||||
CacheProperties props = context.getBean(CacheProperties.class);
|
||||
|
||||
assertThat(props.getTtlSeconds()).isEqualTo(600L);
|
||||
assertThat(props.getCacheType()).isEqualTo("REDIS");
|
||||
assertThat(props.getMaxSize()).isEqualTo(1000); // Default unchanged
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Test all property bindings** including nested structures
|
||||
- **Test validation constraints** thoroughly
|
||||
- **Test both default and custom values**
|
||||
- **Use ApplicationContextRunner** for context-free testing
|
||||
- **Test profile-specific configurations** separately
|
||||
- **Verify type conversions** work correctly
|
||||
- **Test edge cases** (empty strings, null values, type mismatches)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not testing validation constraints
|
||||
- Forgetting to test default values
|
||||
- Not testing nested property structures
|
||||
- Testing with wrong property prefix
|
||||
- Not handling type conversion properly
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Properties not binding**: Verify prefix and property names match exactly (including kebab-case to camelCase conversion).
|
||||
|
||||
**Validation not triggered**: Ensure `@Validated` is present and validation dependencies are on classpath.
|
||||
|
||||
**ApplicationContextRunner not found**: Verify `spring-boot-starter-test` is in test dependencies.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring Boot ConfigurationProperties](https://docs.spring.io/spring-boot/docs/current/reference/html/configuration-metadata.html)
|
||||
- [ApplicationContextRunner Testing](https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/context/runner/ApplicationContextRunner.html)
|
||||
- [Spring Profiles](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.profiles)
|
||||
351
skills/junit-test/unit-test-controller-layer/SKILL.md
Normal file
351
skills/junit-test/unit-test-controller-layer/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
name: unit-test-controller-layer
|
||||
description: Unit tests for REST controllers using MockMvc and @WebMvcTest. Test request/response mapping, validation, and exception handling. Use when testing web layer endpoints in isolation.
|
||||
category: testing
|
||||
tags: [junit-5, mockito, unit-testing, controller, rest, mockmvc]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing REST Controllers with MockMvc
|
||||
|
||||
Test @RestController and @Controller classes by mocking service dependencies and verifying HTTP responses, status codes, and serialization. Use MockMvc for lightweight controller testing without loading the full Spring context.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing REST controller request/response handling
|
||||
- Verifying HTTP status codes and response formats
|
||||
- Testing request parameter binding and validation
|
||||
- Mocking service layer for isolated controller tests
|
||||
- Testing content negotiation and response headers
|
||||
- Want fast controller tests without integration test overhead
|
||||
|
||||
## Setup: MockMvc + Mockito
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.mockito:mockito-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Testing GET Endpoint
|
||||
|
||||
### Simple GET Endpoint Test
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserControllerTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@InjectMocks
|
||||
private UserController userController;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAllUsers() throws Exception {
|
||||
List<UserDto> users = List.of(
|
||||
new UserDto(1L, "Alice"),
|
||||
new UserDto(2L, "Bob")
|
||||
);
|
||||
when(userService.getAllUsers()).thenReturn(users);
|
||||
|
||||
mockMvc.perform(get("/api/users"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].id").value(1))
|
||||
.andExpect(jsonPath("$[0].name").value("Alice"))
|
||||
.andExpect(jsonPath("$[1].id").value(2));
|
||||
|
||||
verify(userService, times(1)).getAllUsers();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUserById() throws Exception {
|
||||
UserDto user = new UserDto(1L, "Alice");
|
||||
when(userService.getUserById(1L)).thenReturn(user);
|
||||
|
||||
mockMvc.perform(get("/api/users/1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(1))
|
||||
.andExpect(jsonPath("$.name").value("Alice"));
|
||||
|
||||
verify(userService).getUserById(1L);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing POST Endpoint
|
||||
|
||||
### Create Resource with Request Body
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldCreateUserAndReturn201() throws Exception {
|
||||
UserCreateRequest request = new UserCreateRequest("Alice", "alice@example.com");
|
||||
UserDto createdUser = new UserDto(1L, "Alice", "alice@example.com");
|
||||
|
||||
when(userService.createUser(any(UserCreateRequest.class)))
|
||||
.thenReturn(createdUser);
|
||||
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"Alice\",\"email\":\"alice@example.com\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(1))
|
||||
.andExpect(jsonPath("$.name").value("Alice"))
|
||||
.andExpect(jsonPath("$.email").value("alice@example.com"));
|
||||
|
||||
verify(userService).createUser(any(UserCreateRequest.class));
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Error Scenarios
|
||||
|
||||
### Handle 404 Not Found
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturn404WhenUserNotFound() throws Exception {
|
||||
when(userService.getUserById(999L))
|
||||
.thenThrow(new UserNotFoundException("User not found"));
|
||||
|
||||
mockMvc.perform(get("/api/users/999"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error").value("User not found"));
|
||||
|
||||
verify(userService).getUserById(999L);
|
||||
}
|
||||
```
|
||||
|
||||
### Handle 400 Bad Request
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturn400WhenRequestBodyInvalid() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"\"}")) // Empty name
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errors").isArray());
|
||||
}
|
||||
```
|
||||
|
||||
## Testing PUT/PATCH Endpoints
|
||||
|
||||
### Update Resource
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldUpdateUserAndReturn200() throws Exception {
|
||||
UserUpdateRequest request = new UserUpdateRequest("Alice Updated");
|
||||
UserDto updatedUser = new UserDto(1L, "Alice Updated");
|
||||
|
||||
when(userService.updateUser(eq(1L), any(UserUpdateRequest.class)))
|
||||
.thenReturn(updatedUser);
|
||||
|
||||
mockMvc.perform(put("/api/users/1")
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"Alice Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(1))
|
||||
.andExpect(jsonPath("$.name").value("Alice Updated"));
|
||||
|
||||
verify(userService).updateUser(eq(1L), any(UserUpdateRequest.class));
|
||||
}
|
||||
```
|
||||
|
||||
## Testing DELETE Endpoint
|
||||
|
||||
### Delete Resource
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldDeleteUserAndReturn204() throws Exception {
|
||||
doNothing().when(userService).deleteUser(1L);
|
||||
|
||||
mockMvc.perform(delete("/api/users/1"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).deleteUser(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn404WhenDeletingNonExistentUser() throws Exception {
|
||||
doThrow(new UserNotFoundException("User not found"))
|
||||
.when(userService).deleteUser(999L);
|
||||
|
||||
mockMvc.perform(delete("/api/users/999"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Request Parameters
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldFilterUsersByName() throws Exception {
|
||||
List<UserDto> users = List.of(new UserDto(1L, "Alice"));
|
||||
when(userService.searchUsers("Alice")).thenReturn(users);
|
||||
|
||||
mockMvc.perform(get("/api/users/search?name=Alice"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].name").value("Alice"));
|
||||
|
||||
verify(userService).searchUsers("Alice");
|
||||
}
|
||||
```
|
||||
|
||||
### Path Variables
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldGetUserByIdFromPath() throws Exception {
|
||||
UserDto user = new UserDto(123L, "Alice");
|
||||
when(userService.getUserById(123L)).thenReturn(user);
|
||||
|
||||
mockMvc.perform(get("/api/users/{id}", 123L))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(123));
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Response Headers
|
||||
|
||||
### Verify Response Headers
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturnCustomHeaders() throws Exception {
|
||||
when(userService.getAllUsers()).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/users"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().exists("X-Total-Count"))
|
||||
.andExpect(header().string("X-Total-Count", "0"))
|
||||
.andExpect(header().string("Content-Type", containsString("application/json")));
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Request Headers
|
||||
|
||||
### Send Request Headers
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldRequireAuthorizationHeader() throws Exception {
|
||||
mockMvc.perform(get("/api/users"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
mockMvc.perform(get("/api/users")
|
||||
.header("Authorization", "Bearer token123"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
```
|
||||
|
||||
## Content Negotiation
|
||||
|
||||
### Test Different Accept Headers
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturnJsonWhenAcceptHeaderIsJson() throws Exception {
|
||||
UserDto user = new UserDto(1L, "Alice");
|
||||
when(userService.getUserById(1L)).thenReturn(user);
|
||||
|
||||
mockMvc.perform(get("/api/users/1")
|
||||
.accept("application/json"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType("application/json"));
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced: Testing Multiple Status Codes
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturnDifferentStatusCodesForDifferentScenarios() throws Exception {
|
||||
// Successful response
|
||||
when(userService.getUserById(1L)).thenReturn(new UserDto(1L, "Alice"));
|
||||
mockMvc.perform(get("/api/users/1"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Not found
|
||||
when(userService.getUserById(999L))
|
||||
.thenThrow(new UserNotFoundException("Not found"));
|
||||
mockMvc.perform(get("/api/users/999"))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
// Unauthorized
|
||||
mockMvc.perform(get("/api/admin/users"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use standalone setup** when testing single controller: `MockMvcBuilders.standaloneSetup()`
|
||||
- **Mock service layer** - controllers should focus on HTTP handling
|
||||
- **Test happy path and error paths** thoroughly
|
||||
- **Verify service method calls** to ensure controller delegates correctly
|
||||
- **Use content() matchers** for response body validation
|
||||
- **Keep tests focused** on one endpoint behavior per test
|
||||
- **Use JsonPath** for fluent JSON response assertions
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Testing business logic in controller**: Move to service tests
|
||||
- **Not mocking service layer**: Always mock service dependencies
|
||||
- **Testing framework behavior**: Focus on your code, not Spring code
|
||||
- **Hardcoding URLs**: Use MockMvcRequestBuilders helpers
|
||||
- **Not verifying mock interactions**: Always verify service was called correctly
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Content type mismatch**: Ensure `contentType()` matches controller's `@PostMapping(consumes=...)` or use default.
|
||||
|
||||
**JsonPath not matching**: Use `mockMvc.perform(...).andDo(print())` to see actual response content.
|
||||
|
||||
**Status code assertions fail**: Check controller `@RequestMapping`, `@PostMapping` status codes and error handling.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring MockMvc Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/MockMvc.html)
|
||||
- [JsonPath for REST Assertions](https://goessner.net/articles/JsonPath/)
|
||||
- [Spring Testing Best Practices](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing)
|
||||
466
skills/junit-test/unit-test-exception-handler/SKILL.md
Normal file
466
skills/junit-test/unit-test-exception-handler/SKILL.md
Normal file
@@ -0,0 +1,466 @@
|
||||
---
|
||||
name: unit-test-exception-handler
|
||||
description: Unit tests for @ExceptionHandler and @ControllerAdvice for global exception handling. Use when validating error response formatting and HTTP status codes.
|
||||
category: testing
|
||||
tags: [junit-5, exception-handler, controller-advice, error-handling, mockmvc]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing ExceptionHandler and ControllerAdvice
|
||||
|
||||
Test exception handlers and global exception handling logic using MockMvc. Verify error response formatting, HTTP status codes, and exception-to-response mapping.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing @ExceptionHandler methods in @ControllerAdvice
|
||||
- Testing exception-to-error-response transformations
|
||||
- Verifying HTTP status codes for different exception types
|
||||
- Testing error message formatting and localization
|
||||
- Want fast exception handler tests without full integration tests
|
||||
|
||||
## Setup: Exception Handler Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Global Exception Handler
|
||||
|
||||
### Create Exception Handler
|
||||
|
||||
```java
|
||||
// Global exception handler
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex) {
|
||||
return new ErrorResponse(
|
||||
HttpStatus.NOT_FOUND.value(),
|
||||
"Resource not found",
|
||||
ex.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ValidationException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ErrorResponse handleValidationException(ValidationException ex) {
|
||||
return new ErrorResponse(
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Validation failed",
|
||||
ex.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Error response DTO
|
||||
public record ErrorResponse(
|
||||
int status,
|
||||
String error,
|
||||
String message
|
||||
) {}
|
||||
```
|
||||
|
||||
### Unit Test Exception Handler
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
@InjectMocks
|
||||
private GlobalExceptionHandler exceptionHandler;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new TestController())
|
||||
.setControllerAdvice(exceptionHandler)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNotFoundWhenResourceNotFoundException() throws Exception {
|
||||
mockMvc.perform(get("/api/users/999"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.status").value(404))
|
||||
.andExpect(jsonPath("$.error").value("Resource not found"))
|
||||
.andExpect(jsonPath("$.message").value("User not found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenValidationException() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.status").value(400))
|
||||
.andExpect(jsonPath("$.error").value("Validation failed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Test controller that throws exceptions
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
class TestController {
|
||||
|
||||
@GetMapping("/users/{id}")
|
||||
public User getUser(@PathVariable Long id) {
|
||||
throw new ResourceNotFoundException("User not found");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Multiple Exception Types
|
||||
|
||||
### Handle Various Exception Types
|
||||
|
||||
```java
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex) {
|
||||
return new ErrorResponse(404, "Not found", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(DuplicateResourceException.class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
public ErrorResponse handleDuplicateResource(DuplicateResourceException ex) {
|
||||
return new ErrorResponse(409, "Conflict", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
public ErrorResponse handleUnauthorized(UnauthorizedException ex) {
|
||||
return new ErrorResponse(401, "Unauthorized", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
|
||||
return new ErrorResponse(403, "Forbidden", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ErrorResponse handleGenericException(Exception ex) {
|
||||
return new ErrorResponse(500, "Internal server error", "An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
|
||||
class MultiExceptionHandlerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new GlobalExceptionHandler();
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new TestController())
|
||||
.setControllerAdvice(handler)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn404ForNotFound() throws Exception {
|
||||
mockMvc.perform(get("/api/users/999"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.status").value(404));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn409ForDuplicate() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"email\":\"existing@example.com\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.status").value(409));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForUnauthorized() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/dashboard"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.status").value(401));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403ForAccessDenied() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/users"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.status").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn500ForGenericException() throws Exception {
|
||||
mockMvc.perform(get("/api/error"))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.status").value(500));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Error Response Structure
|
||||
|
||||
### Verify Error Response Format
|
||||
|
||||
```java
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BadRequestException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ResponseEntity<ErrorDetails> handleBadRequest(BadRequestException ex) {
|
||||
ErrorDetails details = new ErrorDetails(
|
||||
System.currentTimeMillis(),
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Bad Request",
|
||||
ex.getMessage(),
|
||||
new Date()
|
||||
);
|
||||
return new ResponseEntity<>(details, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorResponseStructureTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new TestController())
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIncludeTimestampInErrorResponse() throws Exception {
|
||||
mockMvc.perform(post("/api/data")
|
||||
.contentType("application/json")
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.timestamp").exists())
|
||||
.andExpect(jsonPath("$.status").value(400))
|
||||
.andExpect(jsonPath("$.error").value("Bad Request"))
|
||||
.andExpect(jsonPath("$.message").exists())
|
||||
.andExpect(jsonPath("$.date").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIncludeAllRequiredErrorFields() throws Exception {
|
||||
MvcResult result = mockMvc.perform(get("/api/invalid"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andReturn();
|
||||
|
||||
String response = result.getResponse().getContentAsString();
|
||||
|
||||
assertThat(response).contains("timestamp");
|
||||
assertThat(response).contains("status");
|
||||
assertThat(response).contains("error");
|
||||
assertThat(response).contains("message");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Validation Error Handling
|
||||
|
||||
### Handle MethodArgumentNotValidException
|
||||
|
||||
```java
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ValidationErrorResponse handleValidationException(
|
||||
MethodArgumentNotValidException ex) {
|
||||
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
ex.getBindingResult().getFieldErrors().forEach(error ->
|
||||
errors.put(error.getField(), error.getDefaultMessage())
|
||||
);
|
||||
|
||||
return new ValidationErrorResponse(
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Validation failed",
|
||||
errors
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ValidationExceptionHandlerTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new UserController())
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnValidationErrorsForInvalidInput() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"\",\"age\":-5}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.status").value(400))
|
||||
.andExpect(jsonPath("$.errors.name").exists())
|
||||
.andExpect(jsonPath("$.errors.age").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIncludeErrorMessageForEachField() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"\",\"email\":\"invalid\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.errors.name").value("must not be blank"))
|
||||
.andExpect(jsonPath("$.errors.email").value("must be valid email"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Exception Handler with Custom Logic
|
||||
|
||||
### Exception Handler with Context
|
||||
|
||||
```java
|
||||
@ControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final LoggingService loggingService;
|
||||
|
||||
public GlobalExceptionHandler(MessageService messageService, LoggingService loggingService) {
|
||||
this.messageService = messageService;
|
||||
this.loggingService = loggingService;
|
||||
}
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ErrorResponse handleBusinessException(BusinessException ex, HttpServletRequest request) {
|
||||
loggingService.logException(ex, request.getRequestURI());
|
||||
|
||||
String localizedMessage = messageService.getMessage(ex.getErrorCode());
|
||||
return new ErrorResponse(
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
"Business error",
|
||||
localizedMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExceptionHandlerWithContextTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private GlobalExceptionHandler handler;
|
||||
private MessageService messageService;
|
||||
private LoggingService loggingService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
messageService = mock(MessageService.class);
|
||||
loggingService = mock(LoggingService.class);
|
||||
handler = new GlobalExceptionHandler(messageService, loggingService);
|
||||
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new TestController())
|
||||
.setControllerAdvice(handler)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLocalizeErrorMessage() throws Exception {
|
||||
when(messageService.getMessage("USER_NOT_FOUND"))
|
||||
.thenReturn("L'utilisateur n'a pas été trouvé");
|
||||
|
||||
mockMvc.perform(get("/api/users/999"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("L'utilisateur n'a pas été trouvé"));
|
||||
|
||||
verify(messageService).getMessage("USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLogExceptionOccurrence() throws Exception {
|
||||
mockMvc.perform(get("/api/users/999"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(loggingService).logException(any(BusinessException.class), anyString());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Test all exception handlers** with real exception throws
|
||||
- **Verify HTTP status codes** for each exception type
|
||||
- **Test error response structure** to ensure consistency
|
||||
- **Verify logging** is triggered appropriately
|
||||
- **Use mock controllers** to throw exceptions in tests
|
||||
- **Test both happy and error paths**
|
||||
- **Keep error messages user-friendly** and consistent
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not testing the full request path (use MockMvc with controller)
|
||||
- Forgetting to include `@ControllerAdvice` in MockMvc setup
|
||||
- Not verifying all required fields in error response
|
||||
- Testing handler logic instead of exception handling behavior
|
||||
- Not testing edge cases (null exceptions, unusual messages)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Exception handler not invoked**: Ensure controller is registered with MockMvc and actually throws the exception.
|
||||
|
||||
**JsonPath matchers not matching**: Use `.andDo(print())` to see actual response structure.
|
||||
|
||||
**Status code mismatch**: Verify `@ResponseStatus` annotation on handler method.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring ControllerAdvice Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ControllerAdvice.html)
|
||||
- [Spring ExceptionHandler](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ExceptionHandler.html)
|
||||
- [MockMvc Testing](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/MockMvc.html)
|
||||
398
skills/junit-test/unit-test-json-serialization/SKILL.md
Normal file
398
skills/junit-test/unit-test-json-serialization/SKILL.md
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
name: unit-test-json-serialization
|
||||
description: Unit tests for JSON serialization/deserialization with Jackson and @JsonTest. Use when validating JSON mapping, custom serializers, and date format handling.
|
||||
category: testing
|
||||
tags: [junit-5, json-test, jackson, serialization, deserialization]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing JSON Serialization with @JsonTest
|
||||
|
||||
Test JSON serialization and deserialization of POJOs using Spring's @JsonTest. Verify Jackson configuration, custom serializers, and JSON mapping accuracy.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing JSON serialization of DTOs
|
||||
- Testing JSON deserialization to objects
|
||||
- Testing custom Jackson serializers/deserializers
|
||||
- Verifying JSON field names and formats
|
||||
- Testing null handling in JSON
|
||||
- Want fast JSON mapping tests without full Spring context
|
||||
|
||||
## Setup: JSON Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-json</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-json")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: @JsonTest
|
||||
|
||||
### Test JSON Serialization
|
||||
|
||||
```java
|
||||
import org.springframework.boot.test.autoconfigure.json.JsonTest;
|
||||
import org.springframework.boot.test.json.JacksonTester;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@JsonTest
|
||||
class UserDtoJsonTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<UserDto> json;
|
||||
|
||||
@Test
|
||||
void shouldSerializeUserToJson() throws Exception {
|
||||
UserDto user = new UserDto(1L, "Alice", "alice@example.com", 25);
|
||||
|
||||
org.assertj.core.data.Offset result = json.write(user);
|
||||
|
||||
result
|
||||
.extractingJsonPathNumberValue("$.id").isEqualTo(1)
|
||||
.extractingJsonPathStringValue("$.name").isEqualTo("Alice")
|
||||
.extractingJsonPathStringValue("$.email").isEqualTo("alice@example.com")
|
||||
.extractingJsonPathNumberValue("$.age").isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeJsonToUser() throws Exception {
|
||||
String json_content = "{\"id\":1,\"name\":\"Alice\",\"email\":\"alice@example.com\",\"age\":25}";
|
||||
|
||||
UserDto user = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(user)
|
||||
.isNotNull()
|
||||
.hasFieldOrPropertyWithValue("id", 1L)
|
||||
.hasFieldOrPropertyWithValue("name", "Alice")
|
||||
.hasFieldOrPropertyWithValue("email", "alice@example.com")
|
||||
.hasFieldOrPropertyWithValue("age", 25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullFields() throws Exception {
|
||||
String json_content = "{\"id\":1,\"name\":null,\"email\":\"alice@example.com\",\"age\":null}";
|
||||
|
||||
UserDto user = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(user.getName()).isNull();
|
||||
assertThat(user.getAge()).isNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom JSON Properties
|
||||
|
||||
### @JsonProperty and @JsonIgnore
|
||||
|
||||
```java
|
||||
public class Order {
|
||||
@JsonProperty("order_id")
|
||||
private Long id;
|
||||
|
||||
@JsonProperty("total_amount")
|
||||
private BigDecimal amount;
|
||||
|
||||
@JsonIgnore
|
||||
private String internalNote;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@JsonTest
|
||||
class OrderJsonTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<Order> json;
|
||||
|
||||
@Test
|
||||
void shouldMapJsonPropertyNames() throws Exception {
|
||||
String json_content = "{\"order_id\":123,\"total_amount\":99.99,\"createdAt\":\"2024-01-15T10:30:00\"}";
|
||||
|
||||
Order order = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(order.getId()).isEqualTo(123L);
|
||||
assertThat(order.getAmount()).isEqualByComparingTo(new BigDecimal("99.99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIgnoreJsonIgnoreAnnotatedFields() throws Exception {
|
||||
Order order = new Order(123L, new BigDecimal("99.99"));
|
||||
order.setInternalNote("Secret note");
|
||||
|
||||
JsonContent<Order> result = json.write(order);
|
||||
|
||||
assertThat(result.json).doesNotContain("internalNote");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing List Deserialization
|
||||
|
||||
### JSON Arrays
|
||||
|
||||
```java
|
||||
@JsonTest
|
||||
class UserListJsonTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<List<UserDto>> json;
|
||||
|
||||
@Test
|
||||
void shouldDeserializeUserList() throws Exception {
|
||||
String jsonArray = "[{\"id\":1,\"name\":\"Alice\"},{\"id\":2,\"name\":\"Bob\"}]";
|
||||
|
||||
List<UserDto> users = json.parseObject(jsonArray);
|
||||
|
||||
assertThat(users)
|
||||
.hasSize(2)
|
||||
.extracting(UserDto::getName)
|
||||
.containsExactly("Alice", "Bob");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSerializeUserListToJson() throws Exception {
|
||||
List<UserDto> users = List.of(
|
||||
new UserDto(1L, "Alice"),
|
||||
new UserDto(2L, "Bob")
|
||||
);
|
||||
|
||||
JsonContent<List<UserDto>> result = json.write(users);
|
||||
|
||||
result.json.contains("Alice").contains("Bob");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Nested Objects
|
||||
|
||||
### Complex JSON Structures
|
||||
|
||||
```java
|
||||
public class Product {
|
||||
private Long id;
|
||||
private String name;
|
||||
private Category category;
|
||||
private List<Review> reviews;
|
||||
}
|
||||
|
||||
public class Category {
|
||||
private Long id;
|
||||
private String name;
|
||||
}
|
||||
|
||||
public class Review {
|
||||
private String reviewer;
|
||||
private int rating;
|
||||
private String comment;
|
||||
}
|
||||
|
||||
@JsonTest
|
||||
class ProductJsonTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<Product> json;
|
||||
|
||||
@Test
|
||||
void shouldSerializeNestedObjects() throws Exception {
|
||||
Category category = new Category(1L, "Electronics");
|
||||
Product product = new Product(1L, "Laptop", category);
|
||||
|
||||
JsonContent<Product> result = json.write(product);
|
||||
|
||||
result
|
||||
.extractingJsonPathNumberValue("$.id").isEqualTo(1)
|
||||
.extractingJsonPathStringValue("$.name").isEqualTo("Laptop")
|
||||
.extractingJsonPathNumberValue("$.category.id").isEqualTo(1)
|
||||
.extractingJsonPathStringValue("$.category.name").isEqualTo("Electronics");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeNestedObjects() throws Exception {
|
||||
String json_content = "{\"id\":1,\"name\":\"Laptop\",\"category\":{\"id\":1,\"name\":\"Electronics\"}}";
|
||||
|
||||
Product product = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(product.getCategory())
|
||||
.isNotNull()
|
||||
.hasFieldOrPropertyWithValue("name", "Electronics");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleListOfNestedObjects() throws Exception {
|
||||
String json_content = "{\"id\":1,\"name\":\"Laptop\",\"reviews\":[{\"reviewer\":\"John\",\"rating\":5},{\"reviewer\":\"Jane\",\"rating\":4}]}";
|
||||
|
||||
Product product = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(product.getReviews())
|
||||
.hasSize(2)
|
||||
.extracting(Review::getRating)
|
||||
.containsExactly(5, 4);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Date/Time Formatting
|
||||
|
||||
### LocalDateTime and Other Temporal Types
|
||||
|
||||
```java
|
||||
@JsonTest
|
||||
class DateTimeJsonTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<Event> json;
|
||||
|
||||
@Test
|
||||
void shouldFormatDateTimeCorrectly() throws Exception {
|
||||
LocalDateTime dateTime = LocalDateTime.of(2024, 1, 15, 10, 30, 0);
|
||||
Event event = new Event("Conference", dateTime);
|
||||
|
||||
JsonContent<Event> result = json.write(event);
|
||||
|
||||
result.extractingJsonPathStringValue("$.scheduledAt").isEqualTo("2024-01-15T10:30:00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeDateTimeFromJson() throws Exception {
|
||||
String json_content = "{\"name\":\"Conference\",\"scheduledAt\":\"2024-01-15T10:30:00\"}";
|
||||
|
||||
Event event = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(event.getScheduledAt())
|
||||
.isEqualTo(LocalDateTime.of(2024, 1, 15, 10, 30, 0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom Serializers
|
||||
|
||||
### Custom JsonSerializer Implementation
|
||||
|
||||
```java
|
||||
public class CustomMoneySerializer extends JsonSerializer<BigDecimal> {
|
||||
@Override
|
||||
public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
if (value == null) {
|
||||
gen.writeNull();
|
||||
} else {
|
||||
gen.writeString(String.format("$%.2f", value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class Price {
|
||||
@JsonSerialize(using = CustomMoneySerializer.class)
|
||||
private BigDecimal amount;
|
||||
}
|
||||
|
||||
@JsonTest
|
||||
class CustomSerializerTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<Price> json;
|
||||
|
||||
@Test
|
||||
void shouldUseCustomSerializer() throws Exception {
|
||||
Price price = new Price(new BigDecimal("99.99"));
|
||||
|
||||
JsonContent<Price> result = json.write(price);
|
||||
|
||||
result.extractingJsonPathStringValue("$.amount").isEqualTo("$99.99");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Polymorphic Deserialization
|
||||
|
||||
### Type Information in JSON
|
||||
|
||||
```java
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = CreditCard.class, name = "credit_card"),
|
||||
@JsonSubTypes.Type(value = PayPal.class, name = "paypal")
|
||||
})
|
||||
public abstract class PaymentMethod {
|
||||
private String id;
|
||||
}
|
||||
|
||||
@JsonTest
|
||||
class PolymorphicJsonTest {
|
||||
|
||||
@Autowired
|
||||
private JacksonTester<PaymentMethod> json;
|
||||
|
||||
@Test
|
||||
void shouldDeserializeCreditCard() throws Exception {
|
||||
String json_content = "{\"type\":\"credit_card\",\"id\":\"card123\",\"cardNumber\":\"****1234\"}";
|
||||
|
||||
PaymentMethod method = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(method).isInstanceOf(CreditCard.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializePayPal() throws Exception {
|
||||
String json_content = "{\"type\":\"paypal\",\"id\":\"pp123\",\"email\":\"user@paypal.com\"}";
|
||||
|
||||
PaymentMethod method = json.parse(json_content).getObject();
|
||||
|
||||
assertThat(method).isInstanceOf(PayPal.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use @JsonTest** for focused JSON testing
|
||||
- **Test both serialization and deserialization**
|
||||
- **Test null handling** and missing fields
|
||||
- **Test nested and complex structures**
|
||||
- **Verify field name mapping** with @JsonProperty
|
||||
- **Test date/time formatting** thoroughly
|
||||
- **Test edge cases** (empty strings, empty collections)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not testing null values
|
||||
- Not testing nested objects
|
||||
- Forgetting to test field name mappings
|
||||
- Not verifying JSON property presence/absence
|
||||
- Not testing deserialization of invalid JSON
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**JacksonTester not available**: Ensure class is annotated with `@JsonTest`.
|
||||
|
||||
**Field name doesn't match**: Check @JsonProperty annotation and Jackson configuration.
|
||||
|
||||
**DateTime parsing fails**: Verify date format matches Jackson's expected format.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring @JsonTest Documentation](https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/autoconfigure/json/JsonTest.html)
|
||||
- [Jackson ObjectMapper](https://fasterxml.github.io/jackson-databind/javadoc/2.15/com/fasterxml/jackson/databind/ObjectMapper.html)
|
||||
- [JSON Annotations](https://fasterxml.github.io/jackson-annotations/javadoc/2.15/)
|
||||
434
skills/junit-test/unit-test-mapper-converter/SKILL.md
Normal file
434
skills/junit-test/unit-test-mapper-converter/SKILL.md
Normal file
@@ -0,0 +1,434 @@
|
||||
---
|
||||
name: unit-test-mapper-converter
|
||||
description: Unit tests for mappers and converters (MapStruct, custom mappers). Test object transformation logic in isolation. Use when ensuring correct data transformation between DTOs and domain objects.
|
||||
category: testing
|
||||
tags: [junit-5, mapstruct, mapper, dto, entity, converter]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Mappers and Converters
|
||||
|
||||
Test MapStruct mappers and custom converter classes. Verify field mapping accuracy, null handling, type conversions, and nested object transformations.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing MapStruct mapper implementations
|
||||
- Testing custom entity-to-DTO converters
|
||||
- Testing nested object mapping
|
||||
- Verifying null handling in mappers
|
||||
- Testing type conversions and transformations
|
||||
- Want comprehensive mapping test coverage before integration tests
|
||||
|
||||
## Setup: Testing Mappers
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>1.5.5.Final</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.mapstruct:mapstruct:1.5.5.Final")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Testing MapStruct Mapper
|
||||
|
||||
### Simple Entity to DTO Mapping
|
||||
|
||||
```java
|
||||
// Mapper interface
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface UserMapper {
|
||||
UserDto toDto(User user);
|
||||
User toEntity(UserDto dto);
|
||||
List<UserDto> toDtos(List<User> users);
|
||||
}
|
||||
|
||||
// Unit test
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class UserMapperTest {
|
||||
|
||||
private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);
|
||||
|
||||
@Test
|
||||
void shouldMapUserToDto() {
|
||||
User user = new User(1L, "Alice", "alice@example.com", 25);
|
||||
|
||||
UserDto dto = userMapper.toDto(user);
|
||||
|
||||
assertThat(dto)
|
||||
.isNotNull()
|
||||
.extracting("id", "name", "email", "age")
|
||||
.containsExactly(1L, "Alice", "alice@example.com", 25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapDtoToEntity() {
|
||||
UserDto dto = new UserDto(1L, "Alice", "alice@example.com", 25);
|
||||
|
||||
User user = userMapper.toEntity(dto);
|
||||
|
||||
assertThat(user)
|
||||
.isNotNull()
|
||||
.hasFieldOrPropertyWithValue("id", 1L)
|
||||
.hasFieldOrPropertyWithValue("name", "Alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapListOfUsers() {
|
||||
List<User> users = List.of(
|
||||
new User(1L, "Alice", "alice@example.com", 25),
|
||||
new User(2L, "Bob", "bob@example.com", 30)
|
||||
);
|
||||
|
||||
List<UserDto> dtos = userMapper.toDtos(users);
|
||||
|
||||
assertThat(dtos)
|
||||
.hasSize(2)
|
||||
.extracting(UserDto::getName)
|
||||
.containsExactly("Alice", "Bob");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullEntity() {
|
||||
UserDto dto = userMapper.toDto(null);
|
||||
|
||||
assertThat(dto).isNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Nested Object Mapping
|
||||
|
||||
### Map Complex Hierarchies
|
||||
|
||||
```java
|
||||
// Entities with nesting
|
||||
class User {
|
||||
private Long id;
|
||||
private String name;
|
||||
private Address address;
|
||||
private List<Phone> phones;
|
||||
}
|
||||
|
||||
// Mapper with nested mapping
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface UserMapper {
|
||||
UserDto toDto(User user);
|
||||
User toEntity(UserDto dto);
|
||||
}
|
||||
|
||||
// Unit test for nested objects
|
||||
class NestedObjectMapperTest {
|
||||
|
||||
private final UserMapper userMapper = Mappers.getMapper(UserMapper.class);
|
||||
|
||||
@Test
|
||||
void shouldMapNestedAddress() {
|
||||
Address address = new Address("123 Main St", "New York", "NY", "10001");
|
||||
User user = new User(1L, "Alice", address);
|
||||
|
||||
UserDto dto = userMapper.toDto(user);
|
||||
|
||||
assertThat(dto.getAddress())
|
||||
.isNotNull()
|
||||
.hasFieldOrPropertyWithValue("street", "123 Main St")
|
||||
.hasFieldOrPropertyWithValue("city", "New York");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapListOfNestedPhones() {
|
||||
List<Phone> phones = List.of(
|
||||
new Phone("123-456-7890", "MOBILE"),
|
||||
new Phone("987-654-3210", "HOME")
|
||||
);
|
||||
User user = new User(1L, "Alice", null, phones);
|
||||
|
||||
UserDto dto = userMapper.toDto(user);
|
||||
|
||||
assertThat(dto.getPhones())
|
||||
.hasSize(2)
|
||||
.extracting(PhoneDto::getNumber)
|
||||
.containsExactly("123-456-7890", "987-654-3210");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullNestedObjects() {
|
||||
User user = new User(1L, "Alice", null);
|
||||
|
||||
UserDto dto = userMapper.toDto(user);
|
||||
|
||||
assertThat(dto.getAddress()).isNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom Mapping Methods
|
||||
|
||||
### Mapper with @Mapping Annotations
|
||||
|
||||
```java
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface ProductMapper {
|
||||
@Mapping(source = "name", target = "productName")
|
||||
@Mapping(source = "price", target = "salePrice")
|
||||
@Mapping(target = "discount", expression = "java(product.getPrice() * 0.1)")
|
||||
ProductDto toDto(Product product);
|
||||
|
||||
@Mapping(source = "productName", target = "name")
|
||||
@Mapping(source = "salePrice", target = "price")
|
||||
Product toEntity(ProductDto dto);
|
||||
}
|
||||
|
||||
class CustomMappingTest {
|
||||
|
||||
private final ProductMapper mapper = Mappers.getMapper(ProductMapper.class);
|
||||
|
||||
@Test
|
||||
void shouldMapFieldsWithCustomNames() {
|
||||
Product product = new Product(1L, "Laptop", 999.99);
|
||||
|
||||
ProductDto dto = mapper.toDto(product);
|
||||
|
||||
assertThat(dto)
|
||||
.hasFieldOrPropertyWithValue("productName", "Laptop")
|
||||
.hasFieldOrPropertyWithValue("salePrice", 999.99);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCalculateDiscountFromExpression() {
|
||||
Product product = new Product(1L, "Laptop", 100.0);
|
||||
|
||||
ProductDto dto = mapper.toDto(product);
|
||||
|
||||
assertThat(dto.getDiscount()).isEqualTo(10.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReverseMapCustomFields() {
|
||||
ProductDto dto = new ProductDto(1L, "Laptop", 999.99);
|
||||
|
||||
Product product = mapper.toEntity(dto);
|
||||
|
||||
assertThat(product)
|
||||
.hasFieldOrPropertyWithValue("name", "Laptop")
|
||||
.hasFieldOrPropertyWithValue("price", 999.99);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Enum Mapping
|
||||
|
||||
### Map Enums Between Entity and DTO
|
||||
|
||||
```java
|
||||
// Enum with different representation
|
||||
enum UserStatus { ACTIVE, INACTIVE, SUSPENDED }
|
||||
enum UserStatusDto { ENABLED, DISABLED, LOCKED }
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface UserMapper {
|
||||
@ValueMapping(source = "ACTIVE", target = "ENABLED")
|
||||
@ValueMapping(source = "INACTIVE", target = "DISABLED")
|
||||
@ValueMapping(source = "SUSPENDED", target = "LOCKED")
|
||||
UserStatusDto toStatusDto(UserStatus status);
|
||||
}
|
||||
|
||||
class EnumMapperTest {
|
||||
|
||||
private final UserMapper mapper = Mappers.getMapper(UserMapper.class);
|
||||
|
||||
@Test
|
||||
void shouldMapActiveToEnabled() {
|
||||
UserStatusDto dto = mapper.toStatusDto(UserStatus.ACTIVE);
|
||||
assertThat(dto).isEqualTo(UserStatusDto.ENABLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapSuspendedToLocked() {
|
||||
UserStatusDto dto = mapper.toStatusDto(UserStatus.SUSPENDED);
|
||||
assertThat(dto).isEqualTo(UserStatusDto.LOCKED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom Type Conversions
|
||||
|
||||
### Non-MapStruct Custom Converter
|
||||
|
||||
```java
|
||||
// Custom converter class
|
||||
public class DateFormatter {
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
public static String format(LocalDate date) {
|
||||
return date != null ? date.format(formatter) : null;
|
||||
}
|
||||
|
||||
public static LocalDate parse(String dateString) {
|
||||
return dateString != null ? LocalDate.parse(dateString, formatter) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test
|
||||
class DateFormatterTest {
|
||||
|
||||
@Test
|
||||
void shouldFormatLocalDateToString() {
|
||||
LocalDate date = LocalDate.of(2024, 1, 15);
|
||||
|
||||
String result = DateFormatter.format(date);
|
||||
|
||||
assertThat(result).isEqualTo("2024-01-15");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseStringToLocalDate() {
|
||||
String dateString = "2024-01-15";
|
||||
|
||||
LocalDate result = DateFormatter.parse(dateString);
|
||||
|
||||
assertThat(result).isEqualTo(LocalDate.of(2024, 1, 15));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullInFormat() {
|
||||
String result = DateFormatter.format(null);
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleInvalidDateFormat() {
|
||||
assertThatThrownBy(() -> DateFormatter.parse("invalid-date"))
|
||||
.isInstanceOf(DateTimeParseException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Bidirectional Mapping
|
||||
|
||||
### Entity ↔ DTO Round Trip
|
||||
|
||||
```java
|
||||
class BidirectionalMapperTest {
|
||||
|
||||
private final UserMapper mapper = Mappers.getMapper(UserMapper.class);
|
||||
|
||||
@Test
|
||||
void shouldMaintainDataInRoundTrip() {
|
||||
User original = new User(1L, "Alice", "alice@example.com", 25);
|
||||
|
||||
UserDto dto = mapper.toDto(original);
|
||||
User restored = mapper.toEntity(dto);
|
||||
|
||||
assertThat(restored)
|
||||
.hasFieldOrPropertyWithValue("id", original.getId())
|
||||
.hasFieldOrPropertyWithValue("name", original.getName())
|
||||
.hasFieldOrPropertyWithValue("email", original.getEmail())
|
||||
.hasFieldOrPropertyWithValue("age", original.getAge());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveAllFieldsInBothDirections() {
|
||||
Address address = new Address("123 Main", "NYC", "NY", "10001");
|
||||
User user = new User(1L, "Alice", "alice@example.com", 25, address);
|
||||
|
||||
UserDto dto = mapper.toDto(user);
|
||||
User restored = mapper.toEntity(dto);
|
||||
|
||||
assertThat(restored).usingRecursiveComparison().isEqualTo(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Partial Mapping
|
||||
|
||||
### Update Existing Entity from DTO
|
||||
|
||||
```java
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface UserMapper {
|
||||
void updateEntity(@MappingTarget User entity, UserDto dto);
|
||||
}
|
||||
|
||||
class PartialMapperTest {
|
||||
|
||||
private final UserMapper mapper = Mappers.getMapper(UserMapper.class);
|
||||
|
||||
@Test
|
||||
void shouldUpdateExistingEntity() {
|
||||
User existing = new User(1L, "Alice", "alice@old.com", 25);
|
||||
UserDto dto = new UserDto(1L, "Alice", "alice@new.com", 26);
|
||||
|
||||
mapper.updateEntity(existing, dto);
|
||||
|
||||
assertThat(existing)
|
||||
.hasFieldOrPropertyWithValue("email", "alice@new.com")
|
||||
.hasFieldOrPropertyWithValue("age", 26);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotUpdateFieldsNotInDto() {
|
||||
User existing = new User(1L, "Alice", "alice@example.com", 25);
|
||||
UserDto dto = new UserDto(1L, "Bob", null, 0);
|
||||
|
||||
mapper.updateEntity(existing, dto);
|
||||
|
||||
// Assuming null-aware mapping is configured
|
||||
assertThat(existing.getEmail()).isEqualTo("alice@example.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Test all mapper methods** comprehensively
|
||||
- **Verify null handling** for every nullable field
|
||||
- **Test nested objects** independently and together
|
||||
- **Use recursive comparison** for complex nested structures
|
||||
- **Test bidirectional mapping** to catch asymmetries
|
||||
- **Keep mapper tests simple and focused** on transformation correctness
|
||||
- **Use Mappers.getMapper()** for non-Spring standalone tests
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not testing null input cases
|
||||
- Not verifying nested object mappings
|
||||
- Assuming bidirectional mapping is symmetric
|
||||
- Not testing edge cases (empty collections, etc.)
|
||||
- Tight coupling of mapper tests to MapStruct internals
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Null pointer exceptions during mapping**: Check `nullValuePropertyMappingStrategy` and `nullValueCheckStrategy` in `@Mapper`.
|
||||
|
||||
**Enum mapping not working**: Verify `@ValueMapping` annotations correctly map source to target values.
|
||||
|
||||
**Nested mapping produces null**: Ensure nested mapper interfaces are also mapped in parent mapper.
|
||||
|
||||
## References
|
||||
|
||||
- [MapStruct Official Documentation](https://mapstruct.org/)
|
||||
- [MapStruct Mapping Strategies](https://mapstruct.org/documentation/stable/reference/html/)
|
||||
- [JUnit 5 Best Practices](https://junit.org/junit5/docs/current/user-guide/)
|
||||
374
skills/junit-test/unit-test-parameterized/SKILL.md
Normal file
374
skills/junit-test/unit-test-parameterized/SKILL.md
Normal file
@@ -0,0 +1,374 @@
|
||||
---
|
||||
name: unit-test-parameterized
|
||||
description: Parameterized testing patterns with @ParameterizedTest, @ValueSource, @CsvSource. Run single test method with multiple input combinations. Use when testing multiple scenarios with similar logic.
|
||||
category: testing
|
||||
tags: [junit-5, parameterized-test, value-source, csv-source, method-source]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Parameterized Unit Tests with JUnit 5
|
||||
|
||||
Write efficient parameterized unit tests that run the same test logic with multiple input values. Reduce test duplication and improve test coverage using @ParameterizedTest.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing methods with multiple valid inputs
|
||||
- Testing boundary values systematically
|
||||
- Testing multiple invalid inputs for error cases
|
||||
- Want to reduce test duplication
|
||||
- Testing multiple scenarios with similar assertions
|
||||
- Need data-driven testing approach
|
||||
|
||||
## Setup: Parameterized Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: @ValueSource
|
||||
|
||||
### Simple Value Testing
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class StringUtilsTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"hello", "world", "test"})
|
||||
void shouldCapitalizeAllStrings(String input) {
|
||||
String result = StringUtils.capitalize(input);
|
||||
assertThat(result).startsWith(input.substring(0, 1).toUpperCase());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 2, 3, 4, 5})
|
||||
void shouldBePositive(int number) {
|
||||
assertThat(number).isPositive();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void shouldHandleBothBooleanValues(boolean value) {
|
||||
assertThat(value).isNotNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## @MethodSource for Complex Data
|
||||
|
||||
### Factory Method Data Source
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
class CalculatorTest {
|
||||
|
||||
static Stream<org.junit.jupiter.params.provider.Arguments> additionTestCases() {
|
||||
return Stream.of(
|
||||
Arguments.of(1, 2, 3),
|
||||
Arguments.of(0, 0, 0),
|
||||
Arguments.of(-1, 1, 0),
|
||||
Arguments.of(100, 200, 300),
|
||||
Arguments.of(-5, -10, -15)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("additionTestCases")
|
||||
void shouldAddNumbersCorrectly(int a, int b, int expected) {
|
||||
int result = Calculator.add(a, b);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## @CsvSource for Tabular Data
|
||||
|
||||
### CSV-Based Test Data
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
class UserValidationTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"alice@example.com, true",
|
||||
"bob@gmail.com, true",
|
||||
"invalid-email, false",
|
||||
"user@, false",
|
||||
"@example.com, false",
|
||||
"user name@example.com, false"
|
||||
})
|
||||
void shouldValidateEmailAddresses(String email, boolean expected) {
|
||||
boolean result = UserValidator.isValidEmail(email);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"123-456-7890, true",
|
||||
"555-123-4567, true",
|
||||
"1234567890, false",
|
||||
"123-45-6789, false",
|
||||
"abc-def-ghij, false"
|
||||
})
|
||||
void shouldValidatePhoneNumbers(String phone, boolean expected) {
|
||||
boolean result = PhoneValidator.isValid(phone);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## @CsvFileSource for External Data
|
||||
|
||||
### CSV File-Based Testing
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvFileSource;
|
||||
|
||||
class PriceCalculationTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvFileSource(resources = "/test-data/prices.csv", numLinesToSkip = 1)
|
||||
void shouldCalculateTotalPrice(String product, double price, int quantity, double expected) {
|
||||
double total = PriceCalculator.calculateTotal(price, quantity);
|
||||
assertThat(total).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
|
||||
// test-data/prices.csv:
|
||||
// product,price,quantity,expected
|
||||
// Laptop,999.99,1,999.99
|
||||
// Mouse,29.99,3,89.97
|
||||
// Keyboard,79.99,2,159.98
|
||||
```
|
||||
|
||||
## @EnumSource for Enum Testing
|
||||
|
||||
### Enum-Based Test Data
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
|
||||
enum Status { ACTIVE, INACTIVE, PENDING, DELETED }
|
||||
|
||||
class StatusHandlerTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(Status.class)
|
||||
void shouldHandleAllStatuses(Status status) {
|
||||
assertThat(status).isNotNull();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = Status.class, names = {"ACTIVE", "INACTIVE"})
|
||||
void shouldHandleSpecificStatuses(Status status) {
|
||||
assertThat(status).isIn(Status.ACTIVE, Status.INACTIVE);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = Status.class, mode = EnumSource.Mode.EXCLUDE, names = {"DELETED"})
|
||||
void shouldHandleStatusesExcludingDeleted(Status status) {
|
||||
assertThat(status).isNotEqualTo(Status.DELETED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Display Names
|
||||
|
||||
### Readable Test Output
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class DiscountCalculationTest {
|
||||
|
||||
@ParameterizedTest(name = "Discount of {0}% should be calculated correctly")
|
||||
@ValueSource(ints = {5, 10, 15, 20})
|
||||
void shouldApplyDiscount(int discountPercent) {
|
||||
double originalPrice = 100.0;
|
||||
double discounted = DiscountCalculator.apply(originalPrice, discountPercent);
|
||||
double expected = originalPrice * (1 - discountPercent / 100.0);
|
||||
|
||||
assertThat(discounted).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "User role {0} should have {1} permissions")
|
||||
@CsvSource({
|
||||
"ADMIN, 100",
|
||||
"MANAGER, 50",
|
||||
"USER, 10"
|
||||
})
|
||||
void shouldHaveCorrectPermissions(String role, int expectedPermissions) {
|
||||
User user = new User(role);
|
||||
assertThat(user.getPermissionCount()).isEqualTo(expectedPermissions);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Combining Multiple Sources
|
||||
|
||||
### ArgumentsProvider for Complex Scenarios
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.ArgumentsProvider;
|
||||
import org.junit.jupiter.params.provider.ArgumentsSource;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
class RangeValidatorArgumentProvider implements ArgumentsProvider {
|
||||
@Override
|
||||
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
|
||||
return Stream.of(
|
||||
Arguments.of(0, 0, 100, true), // Min boundary
|
||||
Arguments.of(100, 0, 100, true), // Max boundary
|
||||
Arguments.of(50, 0, 100, true), // Middle value
|
||||
Arguments.of(-1, 0, 100, false), // Below range
|
||||
Arguments.of(101, 0, 100, false) // Above range
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RangeValidatorTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ArgumentsSource(RangeValidatorArgumentProvider.class)
|
||||
void shouldValidateRangeCorrectly(int value, int min, int max, boolean expected) {
|
||||
boolean result = RangeValidator.isInRange(value, min, max);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Edge Cases with Parameters
|
||||
|
||||
### Boundary Value Analysis
|
||||
|
||||
```java
|
||||
class BoundaryValueTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {
|
||||
Integer.MIN_VALUE, // Absolute minimum
|
||||
Integer.MIN_VALUE + 1, // Just above minimum
|
||||
-1, // Negative boundary
|
||||
0, // Zero boundary
|
||||
1, // Just above zero
|
||||
Integer.MAX_VALUE - 1, // Just below maximum
|
||||
Integer.MAX_VALUE // Absolute maximum
|
||||
})
|
||||
void shouldHandleAllBoundaryValues(int value) {
|
||||
int incremented = MathUtils.increment(value);
|
||||
assertThat(incremented).isNotLessThan(value);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
", false", // null
|
||||
"'', false", // empty
|
||||
"' ', false", // whitespace only
|
||||
"a, true", // single character
|
||||
"abc, true" // normal
|
||||
})
|
||||
void shouldValidateStrings(String input, boolean expected) {
|
||||
boolean result = StringValidator.isValid(input);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Repeat Tests
|
||||
|
||||
### Run Same Test Multiple Times
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.RepeatedTest;
|
||||
|
||||
class ConcurrencyTest {
|
||||
|
||||
@RepeatedTest(100)
|
||||
void shouldHandleConcurrentAccess() {
|
||||
// Test that might reveal race conditions if run multiple times
|
||||
AtomicInteger counter = new AtomicInteger(0);
|
||||
counter.incrementAndGet();
|
||||
assertThat(counter.get()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use @ParameterizedTest** to reduce test duplication
|
||||
- **Use descriptive display names** with `(name = "...")`
|
||||
- **Test boundary values** systematically
|
||||
- **Keep test logic simple** - focus on single assertion
|
||||
- **Organize test data logically** - group similar scenarios
|
||||
- **Use @MethodSource** for complex test data
|
||||
- **Use @CsvSource** for tabular test data
|
||||
- **Document expected behavior** in test names
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Testing error conditions**:
|
||||
```java
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", " ", null})
|
||||
void shouldThrowExceptionForInvalidInput(String input) {
|
||||
assertThatThrownBy(() -> Parser.parse(input))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
```
|
||||
|
||||
**Testing multiple valid inputs**:
|
||||
```java
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 2, 3, 5, 8, 13})
|
||||
void shouldBeInFibonacciSequence(int number) {
|
||||
assertThat(FibonacciChecker.isFibonacci(number)).isTrue();
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Parameter not matching**: Verify number and type of parameters match test method signature.
|
||||
|
||||
**Display name not showing**: Check parameter syntax in `name = "..."`.
|
||||
|
||||
**CSV parsing error**: Ensure CSV format is correct and quote strings containing commas.
|
||||
|
||||
## References
|
||||
|
||||
- [JUnit 5 Parameterized Tests](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests)
|
||||
- [@ParameterizedTest Documentation](https://junit.org/junit5/docs/current/api/org.junit.jupiter.params/org/junit/jupiter/params/ParameterizedTest.html)
|
||||
- [Boundary Value Analysis](https://en.wikipedia.org/wiki/Boundary-value_analysis)
|
||||
434
skills/junit-test/unit-test-scheduled-async/SKILL.md
Normal file
434
skills/junit-test/unit-test-scheduled-async/SKILL.md
Normal file
@@ -0,0 +1,434 @@
|
||||
---
|
||||
name: unit-test-scheduled-async
|
||||
description: Unit tests for scheduled and async tasks using @Scheduled and @Async. Mock task execution and timing. Use when validating asynchronous operations and scheduling behavior.
|
||||
category: testing
|
||||
tags: [junit-5, scheduled, async, concurrency, completablefuture]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing @Scheduled and @Async Methods
|
||||
|
||||
Test scheduled tasks and async methods using JUnit 5 without running the actual scheduler. Verify execution logic, timing, and asynchronous behavior.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing @Scheduled method logic
|
||||
- Testing @Async method behavior
|
||||
- Verifying CompletableFuture results
|
||||
- Testing async error handling
|
||||
- Want fast tests without actual scheduling
|
||||
- Testing background task logic in isolation
|
||||
|
||||
## Setup: Async/Scheduled Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.awaitility</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.awaitility:awaitility")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Testing @Async Methods
|
||||
|
||||
### Basic Async Testing with CompletableFuture
|
||||
|
||||
```java
|
||||
// Service with async methods
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
@Async
|
||||
public CompletableFuture<Boolean> sendEmailAsync(String to, String subject) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
// Simulate email sending
|
||||
System.out.println("Sending email to " + to);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Async
|
||||
public void notifyUser(String userId) {
|
||||
System.out.println("Notifying user: " + userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class EmailServiceAsyncTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnCompletedFutureWhenSendingEmail() throws Exception {
|
||||
EmailService service = new EmailService();
|
||||
|
||||
CompletableFuture<Boolean> result = service.sendEmailAsync("test@example.com", "Hello");
|
||||
|
||||
Boolean success = result.get(); // Wait for completion
|
||||
assertThat(success).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCompleteWithinTimeout() {
|
||||
EmailService service = new EmailService();
|
||||
|
||||
CompletableFuture<Boolean> result = service.sendEmailAsync("test@example.com", "Hello");
|
||||
|
||||
assertThat(result)
|
||||
.isCompletedWithValue(true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async with Mocked Dependencies
|
||||
|
||||
### Async Service with Dependencies
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserNotificationService {
|
||||
|
||||
private final EmailService emailService;
|
||||
private final SmsService smsService;
|
||||
|
||||
public UserNotificationService(EmailService emailService, SmsService smsService) {
|
||||
this.emailService = emailService;
|
||||
this.smsService = smsService;
|
||||
}
|
||||
|
||||
@Async
|
||||
public CompletableFuture<String> notifyUserAsync(String userId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
emailService.send(userId);
|
||||
smsService.send(userId);
|
||||
return "Notification sent";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotificationServiceAsyncTest {
|
||||
|
||||
@Mock
|
||||
private EmailService emailService;
|
||||
|
||||
@Mock
|
||||
private SmsService smsService;
|
||||
|
||||
@InjectMocks
|
||||
private UserNotificationService notificationService;
|
||||
|
||||
@Test
|
||||
void shouldNotifyUserAsynchronously() throws Exception {
|
||||
CompletableFuture<String> result = notificationService.notifyUserAsync("user123");
|
||||
|
||||
String message = result.get();
|
||||
assertThat(message).isEqualTo("Notification sent");
|
||||
|
||||
verify(emailService).send("user123");
|
||||
verify(smsService).send("user123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleAsyncExceptionGracefully() {
|
||||
doThrow(new RuntimeException("Email service failed"))
|
||||
.when(emailService).send(any());
|
||||
|
||||
CompletableFuture<String> result = notificationService.notifyUserAsync("user123");
|
||||
|
||||
assertThatThrownBy(result::get)
|
||||
.isInstanceOf(ExecutionException.class)
|
||||
.hasCauseInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing @Scheduled Methods
|
||||
|
||||
### Mock Task Execution
|
||||
|
||||
```java
|
||||
// Scheduled task
|
||||
@Component
|
||||
public class DataRefreshTask {
|
||||
|
||||
private final DataRepository dataRepository;
|
||||
|
||||
public DataRefreshTask(DataRepository dataRepository) {
|
||||
this.dataRepository = dataRepository;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60000)
|
||||
public void refreshCache() {
|
||||
List<Data> data = dataRepository.findAll();
|
||||
// Update cache
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 * * * *") // Every hour
|
||||
public void cleanupOldData() {
|
||||
dataRepository.deleteOldData(LocalDateTime.now().minusDays(30));
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test - test logic without actual scheduling
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DataRefreshTaskTest {
|
||||
|
||||
@Mock
|
||||
private DataRepository dataRepository;
|
||||
|
||||
@InjectMocks
|
||||
private DataRefreshTask dataRefreshTask;
|
||||
|
||||
@Test
|
||||
void shouldRefreshCacheFromRepository() {
|
||||
List<Data> expectedData = List.of(new Data(1L, "item1"));
|
||||
when(dataRepository.findAll()).thenReturn(expectedData);
|
||||
|
||||
dataRefreshTask.refreshCache(); // Call method directly
|
||||
|
||||
verify(dataRepository).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCleanupOldData() {
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
|
||||
|
||||
dataRefreshTask.cleanupOldData();
|
||||
|
||||
verify(dataRepository).deleteOldData(any(LocalDateTime.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async with Awaility
|
||||
|
||||
### Wait for Async Completion
|
||||
|
||||
```java
|
||||
import org.awaitility.Awaitility;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
public class BackgroundWorker {
|
||||
|
||||
private final AtomicInteger processedCount = new AtomicInteger(0);
|
||||
|
||||
@Async
|
||||
public void processItems(List<String> items) {
|
||||
items.forEach(item -> {
|
||||
// Process item
|
||||
processedCount.incrementAndGet();
|
||||
});
|
||||
}
|
||||
|
||||
public int getProcessedCount() {
|
||||
return processedCount.get();
|
||||
}
|
||||
}
|
||||
|
||||
class AwaitilityAsyncTest {
|
||||
|
||||
@Test
|
||||
void shouldProcessAllItemsAsynchronously() {
|
||||
BackgroundWorker worker = new BackgroundWorker();
|
||||
List<String> items = List.of("item1", "item2", "item3");
|
||||
|
||||
worker.processItems(items);
|
||||
|
||||
// Wait for async operation to complete (up to 5 seconds)
|
||||
Awaitility.await()
|
||||
.atMost(Duration.ofSeconds(5))
|
||||
.pollInterval(Duration.ofMillis(100))
|
||||
.untilAsserted(() -> {
|
||||
assertThat(worker.getProcessedCount()).isEqualTo(3);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTimeoutWhenProcessingTakesTooLong() {
|
||||
BackgroundWorker worker = new BackgroundWorker();
|
||||
List<String> items = List.of("item1", "item2", "item3");
|
||||
|
||||
worker.processItems(items);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
Awaitility.await()
|
||||
.atMost(Duration.ofMillis(100))
|
||||
.until(() -> worker.getProcessedCount() == 10)
|
||||
).isInstanceOf(ConditionTimeoutException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async Error Handling
|
||||
|
||||
### Handle Exceptions in Async Methods
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class DataProcessingService {
|
||||
|
||||
@Async
|
||||
public CompletableFuture<Boolean> processDataAsync(String data) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
if (data == null || data.isEmpty()) {
|
||||
throw new IllegalArgumentException("Data cannot be empty");
|
||||
}
|
||||
// Process data
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Async
|
||||
public CompletableFuture<String> safeFetchData(String id) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return fetchData(id);
|
||||
} catch (Exception e) {
|
||||
return "Error: " + e.getMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AsyncErrorHandlingTest {
|
||||
|
||||
@Test
|
||||
void shouldPropagateExceptionFromAsyncMethod() {
|
||||
DataProcessingService service = new DataProcessingService();
|
||||
|
||||
CompletableFuture<Boolean> result = service.processDataAsync(null);
|
||||
|
||||
assertThatThrownBy(result::get)
|
||||
.isInstanceOf(ExecutionException.class)
|
||||
.hasCauseInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Data cannot be empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleExceptionGracefullyWithFallback() throws Exception {
|
||||
DataProcessingService service = new DataProcessingService();
|
||||
|
||||
CompletableFuture<String> result = service.safeFetchData("invalid");
|
||||
|
||||
String message = result.get();
|
||||
assertThat(message).startsWith("Error:");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Scheduled Task Timing
|
||||
|
||||
### Test Schedule Configuration
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class HealthCheckTask {
|
||||
|
||||
private final HealthCheckService healthCheckService;
|
||||
private int executionCount = 0;
|
||||
|
||||
public HealthCheckTask(HealthCheckService healthCheckService) {
|
||||
this.healthCheckService = healthCheckService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 5000) // Every 5 seconds
|
||||
public void checkHealth() {
|
||||
executionCount++;
|
||||
healthCheckService.check();
|
||||
}
|
||||
|
||||
public int getExecutionCount() {
|
||||
return executionCount;
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduledTaskTimingTest {
|
||||
|
||||
@Test
|
||||
void shouldExecuteTaskMultipleTimes() {
|
||||
HealthCheckService mockService = mock(HealthCheckService.class);
|
||||
HealthCheckTask task = new HealthCheckTask(mockService);
|
||||
|
||||
// Execute manually multiple times
|
||||
task.checkHealth();
|
||||
task.checkHealth();
|
||||
task.checkHealth();
|
||||
|
||||
assertThat(task.getExecutionCount()).isEqualTo(3);
|
||||
verify(mockService, times(3)).check();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Test async method logic directly** without Spring async executor
|
||||
- **Use CompletableFuture.get()** to wait for results in tests
|
||||
- **Mock dependencies** that async methods use
|
||||
- **Test error paths** for async operations
|
||||
- **Use Awaitility** when testing actual async behavior is needed
|
||||
- **Mock scheduled tasks** by calling methods directly in tests
|
||||
- **Verify task execution count** for testing scheduling logic
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Testing with actual @Async executor (use direct method calls instead)
|
||||
- Not waiting for CompletableFuture completion in tests
|
||||
- Forgetting to test exception handling in async methods
|
||||
- Not mocking dependencies that async methods call
|
||||
- Trying to test actual scheduling timing (test logic instead)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**CompletableFuture hangs in test**: Ensure methods complete or set timeout with `.get(timeout, unit)`.
|
||||
|
||||
**Async method not executing**: Call method directly instead of relying on @Async in tests.
|
||||
|
||||
**Awaitility timeout**: Increase timeout duration or reduce polling interval.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring @Async Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Async.html)
|
||||
- [Spring @Scheduled Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Scheduled.html)
|
||||
- [Awaitility Testing Library](https://github.com/awaitility/awaitility)
|
||||
- [CompletableFuture API](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html)
|
||||
476
skills/junit-test/unit-test-security-authorization/SKILL.md
Normal file
476
skills/junit-test/unit-test-security-authorization/SKILL.md
Normal file
@@ -0,0 +1,476 @@
|
||||
---
|
||||
name: unit-test-security-authorization
|
||||
description: Unit tests for Spring Security with @PreAuthorize, @Secured, @RolesAllowed. Test role-based access control and authorization policies. Use when validating security configurations and access control logic.
|
||||
category: testing
|
||||
tags: [junit-5, spring-security, authorization, roles, preauthorize, mockmvc]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Security and Authorization
|
||||
|
||||
Test Spring Security authorization logic using @PreAuthorize, @Secured, and custom permission evaluators. Verify access control decisions without full security infrastructure.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing @PreAuthorize and @Secured method-level security
|
||||
- Testing role-based access control (RBAC)
|
||||
- Testing custom permission evaluators
|
||||
- Verifying access denied scenarios
|
||||
- Testing authorization with authenticated principals
|
||||
- Want fast authorization tests without full Spring Security context
|
||||
|
||||
## Setup: Security Testing
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Testing @PreAuthorize
|
||||
|
||||
### Simple Role-Based Access Control
|
||||
|
||||
```java
|
||||
// Service with security annotations
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public void deleteUser(Long userId) {
|
||||
// delete logic
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('USER')")
|
||||
public User getCurrentUser() {
|
||||
// get user logic
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
|
||||
public List<User> listAllUsers() {
|
||||
// list logic
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class UserServiceSecurityTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void shouldAllowAdminToDeleteUser() {
|
||||
UserService service = new UserService();
|
||||
|
||||
assertThatCode(() -> service.deleteUser(1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void shouldDenyUserFromDeletingUser() {
|
||||
UserService service = new UserService();
|
||||
|
||||
assertThatThrownBy(() -> service.deleteUser(1L))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void shouldAllowAdminAndManagerToListUsers() {
|
||||
UserService service = new UserService();
|
||||
|
||||
assertThatCode(() -> service.listAllUsers())
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyAnonymousUserAccess() {
|
||||
UserService service = new UserService();
|
||||
|
||||
assertThatThrownBy(() -> service.deleteUser(1L))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing @Secured Annotation
|
||||
|
||||
### Legacy Security Configuration
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class OrderService {
|
||||
|
||||
@Secured("ROLE_ADMIN")
|
||||
public Order approveOrder(Long orderId) {
|
||||
// approval logic
|
||||
}
|
||||
|
||||
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
|
||||
public List<Order> getOrders() {
|
||||
// get orders
|
||||
}
|
||||
}
|
||||
|
||||
class OrderSecurityTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void shouldAllowAdminToApproveOrder() {
|
||||
OrderService service = new OrderService();
|
||||
|
||||
assertThatCode(() -> service.approveOrder(1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void shouldDenyUserFromApprovingOrder() {
|
||||
OrderService service = new OrderService();
|
||||
|
||||
assertThatThrownBy(() -> service.approveOrder(1L))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Controller Security with MockMvc
|
||||
|
||||
### Secure REST Endpoints
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController {
|
||||
|
||||
@GetMapping("/users")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public List<UserDto> listAllUsers() {
|
||||
// logic
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public void deleteUser(@PathVariable Long id) {
|
||||
// delete logic
|
||||
}
|
||||
}
|
||||
|
||||
// Testing with MockMvc
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class AdminControllerSecurityTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new AdminController())
|
||||
.apply(springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void shouldAllowAdminToListUsers() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/users"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void shouldDenyUserFromListingUsers() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/users"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyAnonymousAccessToAdminEndpoint() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/users"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void shouldAllowAdminToDeleteUser() throws Exception {
|
||||
mockMvc.perform(delete("/api/admin/users/1"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Expression-Based Authorization
|
||||
|
||||
### Complex Permission Expressions
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class DocumentService {
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN') or authentication.principal.username == #owner")
|
||||
public Document getDocument(String owner, Long docId) {
|
||||
// get document
|
||||
}
|
||||
|
||||
@PreAuthorize("hasPermission(#docId, 'Document', 'WRITE')")
|
||||
public void updateDocument(Long docId, String content) {
|
||||
// update logic
|
||||
}
|
||||
|
||||
@PreAuthorize("#userId == authentication.principal.id")
|
||||
public UserProfile getUserProfile(Long userId) {
|
||||
// get profile
|
||||
}
|
||||
}
|
||||
|
||||
class ExpressionBasedSecurityTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice", roles = "ADMIN")
|
||||
void shouldAllowAdminToAccessAnyDocument() {
|
||||
DocumentService service = new DocumentService();
|
||||
|
||||
assertThatCode(() -> service.getDocument("bob", 1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice")
|
||||
void shouldAllowOwnerToAccessOwnDocument() {
|
||||
DocumentService service = new DocumentService();
|
||||
|
||||
assertThatCode(() -> service.getDocument("alice", 1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice")
|
||||
void shouldDenyUserAccessToOtherUserDocument() {
|
||||
DocumentService service = new DocumentService();
|
||||
|
||||
assertThatThrownBy(() -> service.getDocument("bob", 1L))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice", id = "1")
|
||||
void shouldAllowUserToAccessOwnProfile() {
|
||||
DocumentService service = new DocumentService();
|
||||
|
||||
assertThatCode(() -> service.getUserProfile(1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "alice", id = "1")
|
||||
void shouldDenyUserAccessToOtherProfile() {
|
||||
DocumentService service = new DocumentService();
|
||||
|
||||
assertThatThrownBy(() -> service.getUserProfile(999L))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Custom Permission Evaluator
|
||||
|
||||
### Create and Test Custom Permission Logic
|
||||
|
||||
```java
|
||||
// Custom permission evaluator
|
||||
@Component
|
||||
public class DocumentPermissionEvaluator implements PermissionEvaluator {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
|
||||
public DocumentPermissionEvaluator(DocumentRepository documentRepository) {
|
||||
this.documentRepository = documentRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
|
||||
if (authentication == null) return false;
|
||||
|
||||
Document document = (Document) targetDomainObject;
|
||||
String userUsername = authentication.getName();
|
||||
|
||||
return document.getOwner().getUsername().equals(userUsername) ||
|
||||
userHasRole(authentication, "ADMIN");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
|
||||
if (authentication == null) return false;
|
||||
if (!"Document".equals(targetType)) return false;
|
||||
|
||||
Document document = documentRepository.findById((Long) targetId).orElse(null);
|
||||
if (document == null) return false;
|
||||
|
||||
return hasPermission(authentication, document, permission);
|
||||
}
|
||||
|
||||
private boolean userHasRole(Authentication authentication, String role) {
|
||||
return authentication.getAuthorities().stream()
|
||||
.anyMatch(auth -> auth.getAuthority().equals("ROLE_" + role));
|
||||
}
|
||||
}
|
||||
|
||||
// Unit test for custom evaluator
|
||||
class DocumentPermissionEvaluatorTest {
|
||||
|
||||
private DocumentPermissionEvaluator evaluator;
|
||||
private DocumentRepository documentRepository;
|
||||
private Authentication adminAuth;
|
||||
private Authentication userAuth;
|
||||
private Document document;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
documentRepository = mock(DocumentRepository.class);
|
||||
evaluator = new DocumentPermissionEvaluator(documentRepository);
|
||||
|
||||
document = new Document(1L, "Test Doc", new User("alice"));
|
||||
|
||||
adminAuth = new UsernamePasswordAuthenticationToken(
|
||||
"admin",
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
);
|
||||
|
||||
userAuth = new UsernamePasswordAuthenticationToken(
|
||||
"alice",
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGrantPermissionToDocumentOwner() {
|
||||
boolean hasPermission = evaluator.hasPermission(userAuth, document, "WRITE");
|
||||
|
||||
assertThat(hasPermission).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyPermissionToNonOwner() {
|
||||
Authentication otherUserAuth = new UsernamePasswordAuthenticationToken(
|
||||
"bob",
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
|
||||
boolean hasPermission = evaluator.hasPermission(otherUserAuth, document, "WRITE");
|
||||
|
||||
assertThat(hasPermission).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGrantPermissionToAdmin() {
|
||||
boolean hasPermission = evaluator.hasPermission(adminAuth, document, "WRITE");
|
||||
|
||||
assertThat(hasPermission).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyNullAuthentication() {
|
||||
boolean hasPermission = evaluator.hasPermission(null, document, "WRITE");
|
||||
|
||||
assertThat(hasPermission).isFalse();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Multiple Roles
|
||||
|
||||
### Parameterized Role Testing
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class RoleBasedAccessTest {
|
||||
|
||||
private AdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AdminService();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"ADMIN", "SUPER_ADMIN", "SYSTEM"})
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void shouldAllowPrivilegedRolesToDeleteUser(String role) {
|
||||
assertThatCode(() -> service.deleteUser(1L))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"USER", "GUEST", "READONLY"})
|
||||
void shouldDenyUnprivilegedRolesToDeleteUser(String role) {
|
||||
assertThatThrownBy(() -> service.deleteUser(1L))
|
||||
.isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use @WithMockUser** for setting authenticated user context
|
||||
- **Test both allow and deny cases** for each security rule
|
||||
- **Test with different roles** to verify role-based decisions
|
||||
- **Test expression-based security** comprehensively
|
||||
- **Mock external dependencies** (permission evaluators, etc.)
|
||||
- **Test anonymous access separately** from authenticated access
|
||||
- **Use @EnableGlobalMethodSecurity** in configuration for method-level security
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to enable method security in test configuration
|
||||
- Not testing both allow and deny scenarios
|
||||
- Testing framework code instead of authorization logic
|
||||
- Not handling null authentication in tests
|
||||
- Mixing authentication and authorization tests unnecessarily
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**AccessDeniedException not thrown**: Ensure `@EnableGlobalMethodSecurity(prePostEnabled = true)` is configured.
|
||||
|
||||
**@WithMockUser not working**: Verify Spring Security test dependencies are on classpath.
|
||||
|
||||
**Custom PermissionEvaluator not invoked**: Check `@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)`.
|
||||
|
||||
## References
|
||||
|
||||
- [Spring Security Method Security](https://docs.spring.io/spring-security/site/docs/current/reference/html5/#jc-method)
|
||||
- [Spring Security Testing](https://docs.spring.io/spring-security/site/docs/current/reference/html5/#test)
|
||||
- [@WithMockUser Documentation](https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/test/context/support/WithMockUser.html)
|
||||
329
skills/junit-test/unit-test-service-layer/SKILL.md
Normal file
329
skills/junit-test/unit-test-service-layer/SKILL.md
Normal file
@@ -0,0 +1,329 @@
|
||||
---
|
||||
name: unit-test-service-layer
|
||||
description: Unit tests for service layer with Mockito. Test business logic in isolation by mocking dependencies. Use when validating service behaviors and business logic without database or external services.
|
||||
category: testing
|
||||
tags: [junit-5, mockito, unit-testing, service-layer, business-logic]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Service Layer with Mockito
|
||||
|
||||
Test @Service annotated classes by mocking all injected dependencies. Focus on business logic validation without starting the Spring container.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing business logic in @Service classes
|
||||
- Mocking repository and external client dependencies
|
||||
- Verifying service interactions with mocked collaborators
|
||||
- Testing complex workflows and orchestration logic
|
||||
- Want fast, isolated unit tests (no database, no API calls)
|
||||
- Testing error handling and edge cases in services
|
||||
|
||||
## Setup with Mockito and JUnit 5
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core")
|
||||
testImplementation("org.mockito:mockito-junit-jupiter")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Service with Mocked Dependencies
|
||||
|
||||
### Single Dependency
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
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;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturnAllUsers() {
|
||||
// Arrange
|
||||
List<User> expectedUsers = List.of(
|
||||
new User(1L, "Alice"),
|
||||
new User(2L, "Bob")
|
||||
);
|
||||
when(userRepository.findAll()).thenReturn(expectedUsers);
|
||||
|
||||
// Act
|
||||
List<User> result = userService.getAllUsers();
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).containsExactly(
|
||||
new User(1L, "Alice"),
|
||||
new User(2L, "Bob")
|
||||
);
|
||||
verify(userRepository, times(1)).findAll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Dependencies
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserEnrichmentServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private EmailService emailService;
|
||||
|
||||
@Mock
|
||||
private AnalyticsClient analyticsClient;
|
||||
|
||||
@InjectMocks
|
||||
private UserEnrichmentService enrichmentService;
|
||||
|
||||
@Test
|
||||
void shouldCreateUserAndSendWelcomeEmail() {
|
||||
User newUser = new User(1L, "Alice", "alice@example.com");
|
||||
when(userRepository.save(any(User.class))).thenReturn(newUser);
|
||||
doNothing().when(emailService).sendWelcomeEmail(newUser.getEmail());
|
||||
|
||||
User result = enrichmentService.registerNewUser("Alice", "alice@example.com");
|
||||
|
||||
assertThat(result.getId()).isEqualTo(1L);
|
||||
assertThat(result.getName()).isEqualTo("Alice");
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(emailService).sendWelcomeEmail("alice@example.com");
|
||||
verify(analyticsClient, never()).trackUserRegistration(any());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Exception Handling
|
||||
|
||||
### Service Throws Expected Exception
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldThrowExceptionWhenUserNotFound() {
|
||||
when(userRepository.findById(999L))
|
||||
.thenThrow(new UserNotFoundException("User not found"));
|
||||
|
||||
assertThatThrownBy(() -> userService.getUserDetails(999L))
|
||||
.isInstanceOf(UserNotFoundException.class)
|
||||
.hasMessageContaining("User not found");
|
||||
|
||||
verify(userRepository).findById(999L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRethrowRepositoryException() {
|
||||
when(userRepository.findAll())
|
||||
.thenThrow(new DataAccessException("Database connection failed"));
|
||||
|
||||
assertThatThrownBy(() -> userService.getAllUsers())
|
||||
.isInstanceOf(DataAccessException.class)
|
||||
.hasMessageContaining("Database connection failed");
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Complex Workflows
|
||||
|
||||
### Multiple Service Method Calls
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldTransferMoneyBetweenAccounts() {
|
||||
Account fromAccount = new Account(1L, 1000.0);
|
||||
Account toAccount = new Account(2L, 500.0);
|
||||
|
||||
when(accountRepository.findById(1L)).thenReturn(Optional.of(fromAccount));
|
||||
when(accountRepository.findById(2L)).thenReturn(Optional.of(toAccount));
|
||||
when(accountRepository.save(any(Account.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
moneyTransferService.transfer(1L, 2L, 200.0);
|
||||
|
||||
// Verify both accounts were updated
|
||||
verify(accountRepository, times(2)).save(any(Account.class));
|
||||
assertThat(fromAccount.getBalance()).isEqualTo(800.0);
|
||||
assertThat(toAccount.getBalance()).isEqualTo(700.0);
|
||||
}
|
||||
```
|
||||
|
||||
## Argument Capturing and Verification
|
||||
|
||||
### Capture Arguments Passed to Mock
|
||||
|
||||
```java
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@Test
|
||||
void shouldCaptureUserDataWhenSaving() {
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
userService.createUser("Alice", "alice@example.com");
|
||||
|
||||
verify(userRepository).save(userCaptor.capture());
|
||||
User capturedUser = userCaptor.getValue();
|
||||
|
||||
assertThat(capturedUser.getName()).isEqualTo("Alice");
|
||||
assertThat(capturedUser.getEmail()).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCaptureMultipleArgumentsAcrossMultipleCalls() {
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
|
||||
userService.createUser("Alice", "alice@example.com");
|
||||
userService.createUser("Bob", "bob@example.com");
|
||||
|
||||
verify(userRepository, times(2)).save(userCaptor.capture());
|
||||
|
||||
List<User> capturedUsers = userCaptor.getAllValues();
|
||||
assertThat(capturedUsers).hasSize(2);
|
||||
assertThat(capturedUsers.get(0).getName()).isEqualTo("Alice");
|
||||
assertThat(capturedUsers.get(1).getName()).isEqualTo("Bob");
|
||||
}
|
||||
```
|
||||
|
||||
## Verification Patterns
|
||||
|
||||
### Verify Call Order and Frequency
|
||||
|
||||
```java
|
||||
import org.mockito.InOrder;
|
||||
|
||||
@Test
|
||||
void shouldCallMethodsInCorrectOrder() {
|
||||
InOrder inOrder = inOrder(userRepository, emailService);
|
||||
|
||||
userService.registerNewUser("Alice", "alice@example.com");
|
||||
|
||||
inOrder.verify(userRepository).save(any(User.class));
|
||||
inOrder.verify(emailService).sendWelcomeEmail(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCallMethodExactlyOnce() {
|
||||
userService.getUserDetails(1L);
|
||||
|
||||
verify(userRepository, times(1)).findById(1L);
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async/Reactive Services
|
||||
|
||||
### Service with CompletableFuture
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldReturnCompletableFutureWhenFetchingAsyncData() {
|
||||
List<User> users = List.of(new User(1L, "Alice"));
|
||||
when(userRepository.findAllAsync())
|
||||
.thenReturn(CompletableFuture.completedFuture(users));
|
||||
|
||||
CompletableFuture<List<User>> result = userService.getAllUsersAsync();
|
||||
|
||||
assertThat(result).isCompletedWithValue(users);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use @ExtendWith(MockitoExtension.class)** for JUnit 5 integration
|
||||
- **Construct service manually** instead of using reflection when possible
|
||||
- **Mock only direct dependencies** of the service under test
|
||||
- **Verify interactions** to ensure correct collaboration
|
||||
- **Use descriptive variable names**: `expectedUser`, `actualUser`, `captor`
|
||||
- **Test one behavior per test method** - keep tests focused
|
||||
- **Avoid testing framework code** - focus on business logic
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Partial Mock with Spy**:
|
||||
```java
|
||||
@Spy
|
||||
@InjectMocks
|
||||
private UserService userService; // Real instance, but can stub some methods
|
||||
|
||||
@Test
|
||||
void shouldUseRealMethodButMockDependency() {
|
||||
when(userRepository.findById(any())).thenReturn(Optional.of(new User()));
|
||||
// Calls real userService methods but userRepository is mocked
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor Injection for Testing**:
|
||||
```java
|
||||
// In your service (production code)
|
||||
public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserService(UserRepository userRepository) {
|
||||
this.repository = userRepository;
|
||||
}
|
||||
}
|
||||
|
||||
// In your test - can inject mocks directly
|
||||
@Test
|
||||
void test() {
|
||||
UserRepository mockRepo = mock(UserRepository.class);
|
||||
UserService service = new UserService(mockRepo);
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**UnfinishedStubbingException**: Ensure all `when()` calls are completed with `thenReturn()`, `thenThrow()`, or `thenAnswer()`.
|
||||
|
||||
**UnnecessaryStubbingException**: Remove unused stub definitions. Use `@ExtendWith(MockitoExtension.class)` with `MockitoExtension.LENIENT` if you intentionally have unused stubs.
|
||||
|
||||
**NullPointerException in test**: Verify `@InjectMocks` correctly injects all mocked dependencies into the service constructor.
|
||||
|
||||
## References
|
||||
|
||||
- [Mockito Documentation](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html)
|
||||
- [JUnit 5 User Guide](https://junit.org/junit5/docs/current/user-guide/)
|
||||
- [AssertJ Assertions](https://assertj.github.io/assertj-core-features-highlight.html)
|
||||
389
skills/junit-test/unit-test-utility-methods/SKILL.md
Normal file
389
skills/junit-test/unit-test-utility-methods/SKILL.md
Normal file
@@ -0,0 +1,389 @@
|
||||
---
|
||||
name: unit-test-utility-methods
|
||||
description: Unit tests for utility/helper classes and static methods. Test pure functions and helper logic. Use when validating utility code correctness.
|
||||
category: testing
|
||||
tags: [junit-5, unit-testing, utility, static-methods, pure-functions]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing Utility Classes and Static Methods
|
||||
|
||||
Test static utility methods using JUnit 5. Focus on pure functions without side effects, edge cases, and boundary conditions.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing utility classes with static helper methods
|
||||
- Testing pure functions with no state or side effects
|
||||
- Testing string manipulation and formatting utilities
|
||||
- Testing calculation and conversion utilities
|
||||
- Testing collections and array utilities
|
||||
- Want simple, fast tests without mocking complexity
|
||||
- Testing data transformation and validation helpers
|
||||
|
||||
## Basic Pattern: Static Utility Testing
|
||||
|
||||
### Simple String Utility
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class StringUtilsTest {
|
||||
|
||||
@Test
|
||||
void shouldCapitalizeFirstLetter() {
|
||||
String result = StringUtils.capitalize("hello");
|
||||
assertThat(result).isEqualTo("Hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyString() {
|
||||
String result = StringUtils.capitalize("");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullInput() {
|
||||
String result = StringUtils.capitalize(null);
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleSingleCharacter() {
|
||||
String result = StringUtils.capitalize("a");
|
||||
assertThat(result).isEqualTo("A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotChangePascalCase() {
|
||||
String result = StringUtils.capitalize("Hello");
|
||||
assertThat(result).isEqualTo("Hello");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Null Handling
|
||||
|
||||
### Null-Safe Utility Methods
|
||||
|
||||
```java
|
||||
class NullSafeUtilsTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnDefaultValueWhenNull() {
|
||||
Object result = NullSafeUtils.getOrDefault(null, "default");
|
||||
assertThat(result).isEqualTo("default");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnValueWhenNotNull() {
|
||||
Object result = NullSafeUtils.getOrDefault("value", "default");
|
||||
assertThat(result).isEqualTo("value");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnFalseWhenStringIsNull() {
|
||||
boolean result = NullSafeUtils.isNotBlank(null);
|
||||
assertThat(result).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnTrueWhenStringHasContent() {
|
||||
boolean result = NullSafeUtils.isNotBlank(" text ");
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Calculations and Conversions
|
||||
|
||||
### Math Utilities
|
||||
|
||||
```java
|
||||
class MathUtilsTest {
|
||||
|
||||
@Test
|
||||
void shouldCalculatePercentage() {
|
||||
double result = MathUtils.percentage(25, 100);
|
||||
assertThat(result).isEqualTo(25.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleZeroDivisor() {
|
||||
double result = MathUtils.percentage(50, 0);
|
||||
assertThat(result).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRoundToTwoDecimalPlaces() {
|
||||
double result = MathUtils.round(3.14159, 2);
|
||||
assertThat(result).isEqualTo(3.14);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNegativeNumbers() {
|
||||
int result = MathUtils.absoluteValue(-42);
|
||||
assertThat(result).isEqualTo(42);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Collection Utilities
|
||||
|
||||
### List/Set/Map Operations
|
||||
|
||||
```java
|
||||
class CollectionUtilsTest {
|
||||
|
||||
@Test
|
||||
void shouldFilterList() {
|
||||
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
|
||||
List<Integer> evenNumbers = CollectionUtils.filter(numbers, n -> n % 2 == 0);
|
||||
assertThat(evenNumbers).containsExactly(2, 4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenNoMatches() {
|
||||
List<Integer> numbers = List.of(1, 3, 5);
|
||||
List<Integer> evenNumbers = CollectionUtils.filter(numbers, n -> n % 2 == 0);
|
||||
assertThat(evenNumbers).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullList() {
|
||||
List<Integer> result = CollectionUtils.filter(null, n -> true);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldJoinStringsWithSeparator() {
|
||||
String result = CollectionUtils.join(List.of("a", "b", "c"), "-");
|
||||
assertThat(result).isEqualTo("a-b-c");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyList() {
|
||||
String result = CollectionUtils.join(List.of(), "-");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeduplicateList() {
|
||||
List<String> input = List.of("apple", "banana", "apple", "cherry", "banana");
|
||||
Set<String> unique = CollectionUtils.deduplicate(input);
|
||||
assertThat(unique).containsExactlyInAnyOrder("apple", "banana", "cherry");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing String Transformations
|
||||
|
||||
### Format and Parse Utilities
|
||||
|
||||
```java
|
||||
class FormatUtilsTest {
|
||||
|
||||
@Test
|
||||
void shouldFormatCurrencyWithSymbol() {
|
||||
String result = FormatUtils.formatCurrency(1234.56);
|
||||
assertThat(result).isEqualTo("$1,234.56");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNegativeCurrency() {
|
||||
String result = FormatUtils.formatCurrency(-100.00);
|
||||
assertThat(result).isEqualTo("-$100.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParsePhoneNumber() {
|
||||
String result = FormatUtils.parsePhoneNumber("5551234567");
|
||||
assertThat(result).isEqualTo("(555) 123-4567");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFormatDate() {
|
||||
LocalDate date = LocalDate.of(2024, 1, 15);
|
||||
String result = FormatUtils.formatDate(date, "yyyy-MM-dd");
|
||||
assertThat(result).isEqualTo("2024-01-15");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSluggifyString() {
|
||||
String result = FormatUtils.sluggify("Hello World! 123");
|
||||
assertThat(result).isEqualTo("hello-world-123");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Data Validation
|
||||
|
||||
### Validator Utilities
|
||||
|
||||
```java
|
||||
class ValidatorUtilsTest {
|
||||
|
||||
@Test
|
||||
void shouldValidateEmailFormat() {
|
||||
boolean valid = ValidatorUtils.isValidEmail("user@example.com");
|
||||
assertThat(valid).isTrue();
|
||||
|
||||
boolean invalid = ValidatorUtils.isValidEmail("invalid-email");
|
||||
assertThat(invalid).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldValidatePhoneNumber() {
|
||||
boolean valid = ValidatorUtils.isValidPhone("555-123-4567");
|
||||
assertThat(valid).isTrue();
|
||||
|
||||
boolean invalid = ValidatorUtils.isValidPhone("12345");
|
||||
assertThat(invalid).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldValidateUrlFormat() {
|
||||
boolean valid = ValidatorUtils.isValidUrl("https://example.com");
|
||||
assertThat(valid).isTrue();
|
||||
|
||||
boolean invalid = ValidatorUtils.isValidUrl("not a url");
|
||||
assertThat(invalid).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldValidateCreditCardNumber() {
|
||||
boolean valid = ValidatorUtils.isValidCreditCard("4532015112830366");
|
||||
assertThat(valid).isTrue();
|
||||
|
||||
boolean invalid = ValidatorUtils.isValidCreditCard("1234567890123456");
|
||||
assertThat(invalid).isFalse();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Parameterized Scenarios
|
||||
|
||||
### Multiple Test Cases with @ParameterizedTest
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
class StringUtilsParametrizedTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", " ", "null", "undefined"})
|
||||
void shouldConsiderFalsyValuesAsEmpty(String input) {
|
||||
boolean result = StringUtils.isEmpty(input);
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"hello,HELLO",
|
||||
"world,WORLD",
|
||||
"javaScript,JAVASCRIPT",
|
||||
"123ABC,123ABC"
|
||||
})
|
||||
void shouldConvertToUpperCase(String input, String expected) {
|
||||
String result = StringUtils.toUpperCase(input);
|
||||
assertThat(result).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing with Mockito for External Dependencies
|
||||
|
||||
### Utility with Dependency (Rare Case)
|
||||
|
||||
```java
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DateUtilsTest {
|
||||
|
||||
@Mock
|
||||
private Clock clock;
|
||||
|
||||
@Test
|
||||
void shouldGetCurrentDateFromClock() {
|
||||
Instant fixedTime = Instant.parse("2024-01-15T10:30:00Z");
|
||||
when(clock.instant()).thenReturn(fixedTime);
|
||||
|
||||
LocalDate result = DateUtils.today(clock);
|
||||
|
||||
assertThat(result).isEqualTo(LocalDate.of(2024, 1, 15));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases and Boundary Testing
|
||||
|
||||
```java
|
||||
class MathUtilsEdgeCaseTest {
|
||||
|
||||
@Test
|
||||
void shouldHandleMaxIntegerValue() {
|
||||
int result = MathUtils.increment(Integer.MAX_VALUE);
|
||||
assertThat(result).isEqualTo(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleMinIntegerValue() {
|
||||
int result = MathUtils.decrement(Integer.MIN_VALUE);
|
||||
assertThat(result).isEqualTo(Integer.MIN_VALUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleVeryLargeNumbers() {
|
||||
BigDecimal result = MathUtils.add(
|
||||
new BigDecimal("999999999999.99"),
|
||||
new BigDecimal("0.01")
|
||||
);
|
||||
assertThat(result).isEqualTo(new BigDecimal("1000000000000.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleFloatingPointPrecision() {
|
||||
double result = MathUtils.multiply(0.1, 0.2);
|
||||
assertThat(result).isCloseTo(0.02, within(0.0001));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Test pure functions exclusively** - no side effects or state
|
||||
- **Cover happy path and edge cases** - null, empty, extreme values
|
||||
- **Use descriptive test names** - clearly state what's being tested
|
||||
- **Keep tests simple and short** - utility tests should be quick to understand
|
||||
- **Use @ParameterizedTest** for testing multiple similar scenarios
|
||||
- **Avoid mocking when not needed** - only mock external dependencies
|
||||
- **Test boundary conditions** - min/max values, empty collections, null inputs
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Testing framework behavior instead of utility logic
|
||||
- Over-mocking when pure functions need no mocks
|
||||
- Not testing null/empty edge cases
|
||||
- Not testing negative numbers and extreme values
|
||||
- Test methods too large - split complex scenarios
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Floating point precision issues**: Use `isCloseTo()` with delta instead of exact equality.
|
||||
|
||||
**Null handling inconsistency**: Decide whether utility returns null or throws exception, then test consistently.
|
||||
|
||||
**Complex utility logic belongs elsewhere**: Consider refactoring into testable units.
|
||||
|
||||
## References
|
||||
|
||||
- [JUnit 5 Parameterized Tests](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests)
|
||||
- [AssertJ Assertions](https://assertj.github.io/assertj-core-features-highlight.html)
|
||||
- [Testing Edge Cases and Boundaries](https://www.baeldung.com/testing-properties-methods-using-mockito)
|
||||
170
skills/junit-test/unit-test-wiremock-rest-api/SKILL.md
Normal file
170
skills/junit-test/unit-test-wiremock-rest-api/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: unit-test-wiremock-rest-api
|
||||
description: Unit tests for external REST APIs using WireMock to mock HTTP endpoints. Use when testing service integrations with external APIs.
|
||||
category: testing
|
||||
tags: [junit-5, wiremock, unit-testing, rest-api, mocking, http-stubbing]
|
||||
version: 1.0.1
|
||||
---
|
||||
|
||||
# Unit Testing REST APIs with WireMock
|
||||
|
||||
Test interactions with third-party REST APIs without making real network calls using WireMock. This skill focuses on pure unit tests (no Spring context) that stub HTTP responses and verify requests.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Testing services that call external REST APIs
|
||||
- Need to stub HTTP responses for predictable test behavior
|
||||
- Want to test error scenarios (timeouts, 500 errors, malformed responses)
|
||||
- Need to verify request details (headers, query params, request body)
|
||||
- Integrating with third-party services (payment gateways, weather APIs, etc.)
|
||||
- Testing without network dependencies or rate limits
|
||||
- Building unit tests that run fast in CI/CD pipelines
|
||||
|
||||
## Core Dependencies
|
||||
|
||||
### Maven
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Gradle
|
||||
```kotlin
|
||||
dependencies {
|
||||
testImplementation("org.wiremock:wiremock:3.4.1")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.assertj:assertj-core")
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Pattern: Stubbing and Verifying
|
||||
|
||||
### Simple Stub with WireMock Extension
|
||||
|
||||
```java
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ExternalWeatherServiceTest {
|
||||
|
||||
@RegisterExtension
|
||||
static WireMockExtension wireMock = WireMockExtension.newInstance()
|
||||
.options(wireMockConfig().dynamicPort())
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void shouldFetchWeatherDataFromExternalApi() {
|
||||
wireMock.stubFor(get(urlEqualTo("/weather?city=London"))
|
||||
.withHeader("Accept", containing("application/json"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"city\":\"London\",\"temperature\":15,\"condition\":\"Cloudy\"}")));
|
||||
|
||||
String baseUrl = wireMock.getRuntimeInfo().getHttpBaseUrl();
|
||||
WeatherApiClient client = new WeatherApiClient(baseUrl);
|
||||
WeatherData weather = client.getWeather("London");
|
||||
|
||||
assertThat(weather.getCity()).isEqualTo("London");
|
||||
assertThat(weather.getTemperature()).isEqualTo(15);
|
||||
|
||||
wireMock.verify(getRequestedFor(urlEqualTo("/weather?city=London"))
|
||||
.withHeader("Accept", containing("application/json")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Error Scenarios
|
||||
|
||||
### Test 4xx and 5xx Responses
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldHandleNotFoundError() {
|
||||
wireMock.stubFor(get(urlEqualTo("/api/users/999"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(404)
|
||||
.withBody("{\"error\":\"User not found\"}")));
|
||||
|
||||
WeatherApiClient client = new WeatherApiClient(wireMock.getRuntimeInfo().getHttpBaseUrl());
|
||||
|
||||
assertThatThrownBy(() -> client.getUser(999))
|
||||
.isInstanceOf(UserNotFoundException.class)
|
||||
.hasMessageContaining("User not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRetryOnServerError() {
|
||||
wireMock.stubFor(get(urlEqualTo("/api/data"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(500)
|
||||
.withBody("{\"error\":\"Internal server error\"}")));
|
||||
|
||||
ApiClient client = new ApiClient(wireMock.getRuntimeInfo().getHttpBaseUrl());
|
||||
|
||||
assertThatThrownBy(() -> client.fetchData())
|
||||
.isInstanceOf(ServerErrorException.class);
|
||||
}
|
||||
```
|
||||
|
||||
## Request Verification
|
||||
|
||||
### Verify Request Details and Payload
|
||||
|
||||
```java
|
||||
@Test
|
||||
void shouldVerifyRequestBody() {
|
||||
wireMock.stubFor(post(urlEqualTo("/api/users"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(201)
|
||||
.withBody("{\"id\":123,\"name\":\"Alice\"}")));
|
||||
|
||||
ApiClient client = new ApiClient(wireMock.getRuntimeInfo().getHttpBaseUrl());
|
||||
UserResponse response = client.createUser("Alice");
|
||||
|
||||
assertThat(response.getId()).isEqualTo(123);
|
||||
|
||||
wireMock.verify(postRequestedFor(urlEqualTo("/api/users"))
|
||||
.withRequestBody(matchingJsonPath("$.name", equalTo("Alice")))
|
||||
.withHeader("Content-Type", containing("application/json")));
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use dynamic port** to avoid port conflicts in parallel test execution
|
||||
- **Verify requests** to ensure correct API usage
|
||||
- **Test error scenarios** thoroughly
|
||||
- **Keep stubs focused** - one concern per test
|
||||
- **Reset WireMock** between tests automatically via `@RegisterExtension`
|
||||
- **Never call real APIs** - always stub third-party endpoints
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**WireMock not intercepting requests**: Ensure your HTTP client uses the stubbed URL from `wireMock.getRuntimeInfo().getHttpBaseUrl()`.
|
||||
|
||||
**Port conflicts**: Always use `wireMockConfig().dynamicPort()` to let WireMock choose available port.
|
||||
|
||||
## References
|
||||
|
||||
- [WireMock Official Documentation](https://wiremock.org/)
|
||||
- [WireMock Stubs and Mocking](https://wiremock.org/docs/stubbing/)
|
||||
- [JUnit 5 Extensions](https://junit.org/junit5/docs/current/user-guide/#extensions)
|
||||
Reference in New Issue
Block a user