Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:28:34 +08:00
commit 390afca02b
220 changed files with 86013 additions and 0 deletions

View 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)