--- 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 jakarta.validation jakarta.validation-api org.hibernate.validator hibernate-validator test org.junit.jupiter junit-jupiter test org.assertj assertj-core test ``` ### 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> violations = validator.validate(user); assertThat(violations).isEmpty(); } @Test void shouldFailValidationWhenNameIsNull() { User user = new User(null, "alice@example.com", 25); Set> 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> 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> 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> 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> 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> 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> 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> 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[] payload() default {}; } // Custom validator implementation public class PhoneNumberValidator implements ConstraintValidator { 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> violations = validator.validate(contact); assertThat(violations).isEmpty(); } @Test void shouldRejectInvalidPhoneNumberFormat() { Contact contact = new Contact("Alice", "5551234567"); // No dashes Set> 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> violations = validator.validate(contact); assertThat(violations).isNotEmpty(); } @Test void shouldAllowNullPhoneNumber() { Contact contact = new Contact("Alice", null); Set> 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[] payload() default {}; } // Validator implementation public class PasswordMatchValidator implements ConstraintValidator { @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> violations = validator.validate(request); assertThat(violations).isEmpty(); } @Test void shouldFailWhenPasswordsDoNotMatch() { ChangePasswordRequest request = new ChangePasswordRequest("oldPass", "newPass123", "differentPass"); Set> 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> 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> 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> 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> 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)