Unit Testing Bean Validation and Custom Validators
Overview
This skill provides patterns for unit testing Jakarta Bean Validation annotations and custom validator implementations using JUnit 5. It covers testing built-in constraints (@NotNull, @Email, @Min, @Max), creating custom validators, cross-field validation, validation groups, and parameterized testing scenarios.
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
Instructions
-
Add validation dependencies: Include jakarta.validation-api and hibernate-validator in your test classpath
-
Create a Validator instance: Use Validation.buildDefaultValidatorFactory().getValidator() in @BeforeEach
-
Test valid scenarios: Always test that valid objects pass validation without violations
-
Test each constraint separately: Create focused tests for individual validation rules
-
Extract violation details: Use assertions to verify property path, message, and invalid value
-
Test custom validators: Write dedicated tests for each custom constraint implementation
-
Use parameterized tests: Apply @ParameterizedTest for testing multiple invalid inputs efficiently
-
Test validation groups: Verify conditional validation based on validation groups
Examples
Setup: Bean Validation
Maven
<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
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
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
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
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
// 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
// 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
@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
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
Constraints and Warnings
-
Constraints ignore null by default: Except @NotNull, most constraints ignore null values; combine with @NotNull for mandatory fields
-
Validator is thread-safe: Validator instances can be shared across tests, but create new ones for isolation if needed
-
Message localization: Test with different locales if your application supports internationalization
-
Cascading validation: Use @Valid on nested objects to enable cascading validation
-
Performance consideration: Validation has overhead; don't over-validate in critical paths
-
Custom validators must be stateless: Validator implementations should not maintain state between invocations
-
Test in isolation: Validation tests should not depend on Spring context or database
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
-
Hibernate Validator Documentation
-
Custom Constraints