Unit Testing Jakarta Bean Validation
Overview
This skill provides executable patterns for unit testing Jakarta Bean Validation annotations and custom validators using JUnit 5. Covers built-in constraints (@NotNull, @Email, @Min, @Max, @Size), custom @Constraint implementations, cross-field validation, and validation groups. Tests run in isolation without Spring context.
When to Use
- Writing unit tests for Jakarta Bean Validation or JSR-380 constraints
- Testing custom
@Constraintvalidators and constraint violation messages - Testing bean validation logic in DTOs and request objects
- Verifying cross-field validation (e.g., password matching)
- Testing conditional validation with validation groups
- Fast validation tests without Spring Boot context
Instructions
- Add dependencies: Include
jakarta.validation-apiandhibernate-validatorin test scope - Create base test class: Build
Validatoronce in@BeforeEachusingValidation.buildDefaultValidatorFactory() - Test valid cases first: Verify objects pass without violations
- Test invalid cases: Assert constraint violations include correct property path and message
- Extract violation details: Use
getPropertyPath(),getMessage(),getInvalidValue() - Test custom validators: See
references/custom-validators.mdfor patterns - Use parameterized tests: Test multiple inputs efficiently with
@ParameterizedTest - Group validation tests: Use validation groups for conditional rules (see
references/advanced-patterns.md)
Examples
Maven Setup
<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.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Common Test Setup
import jakarta.validation.*;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.path.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class BaseValidationTest {
protected Validator validator;
@BeforeEach
void setUpValidator() {
validator = Validation.buildDefaultValidatorFactory().getValidator();
}
}
Testing Basic Constraints
class UserDtoTest extends BaseValidationTest {
@Test
void shouldPassValidationWithValidUser() {
UserDto user = new UserDto("Alice", "alice@example.com", 25);
assertThat(validator.validate(user)).isEmpty();
}
@Test
void shouldFailWhenNameIsNull() {
UserDto user = new UserDto(null, "alice@example.com", 25);
assertThat(validator.validate(user))
.extracting(ConstraintViolation::getMessage)
.contains("must not be blank");
}
@Test
void shouldFailWhenEmailIsInvalid() {
UserDto user = new UserDto("Alice", "invalid-email", 25);
Set<ConstraintViolation<UserDto>> violations = validator.validate(user);
assertThat(violations)
.extracting(ConstraintViolation::getPropertyPath)
.extracting(Path::toString)
.contains("email");
}
@Test
void shouldFailWhenAgeIsBelowMinimum() {
UserDto user = new UserDto("Alice", "alice@example.com", -1);
assertThat(validator.validate(user))
.extracting(ConstraintViolation::getMessage)
.contains("must be greater than or equal to 0");
}
@Test
void shouldFailWhenMultipleConstraintsViolated() {
UserDto user = new UserDto(null, "invalid", -5);
assertThat(validator.validate(user)).hasSize(3);
}
}
Testing Custom Validators
For custom constraint patterns, see references/custom-validators.md:
- Creating
@Constraintannotations - Implementing
ConstraintValidator - Cross-field validation (password matching)
- Stateless validator best practices
Testing Validation Groups
For validation groups and parameterized tests, see references/advanced-patterns.md:
- Defining validation group interfaces
- Conditional validation with
groupsparameter @ParameterizedTestwith@ValueSourceand@CsvSource- Debugging failed validation tests
Best Practices
- Test both valid and invalid: Every constraint needs both passing and failing test cases
- Assert violation details: Verify property path, message, and constraint type
- Test edge cases: null, empty string, whitespace-only, boundary values
- Keep validators stateless: Custom validators must not maintain state
- Use clear messages: Constraint messages should be user-friendly
- Group related tests: Extend
BaseValidationTestto share validator setup - Test error messages: Ensure messages match requirements
Common Pitfalls
- Forgetting to test null values (most constraints ignore null by default)
- Not verifying the property path in constraint violations
- Testing validation at service/controller level instead of unit level
- Creating overly complex custom validators
- Missing
@NotNullfor mandatory fields combined with other constraints
Constraints and Warnings
- Null handling: Most constraints ignore null by default — combine
@NotNullwith other constraints for mandatory fields - Thread safety:
Validatorinstances are thread-safe and can be shared - Message localization: Test with different locales if i18n is required
- Cascading validation: Use
@Validon nested objects for recursive validation - Custom validators: Must be stateless and return
truefor null values - Test isolation: Validation unit tests should not depend on Spring context or database
Troubleshooting
ValidatorFactory not found: Ensure jakarta.validation-api and hibernate-validator are on test classpath.
Custom validator not invoked: Verify @Constraint(validatedBy = YourValidator.class) annotation is correct.
Null values pass validation: This is expected behavior — constraints ignore null unless @NotNull is present.
Wrong violation count: Use hasSize() to verify exact count, check all fields in the object.
Property path incorrect: Ensure the field, not the getter, has the constraint annotation.
References
- Jakarta Bean Validation Spec
- Hibernate Validator
- Custom validators and cross-field validation:
references/custom-validators.md - Validation groups and parameterized tests:
references/advanced-patterns.md