unit-test-bean-validation

Unit Testing Bean Validation and Custom Validators

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "unit-test-bean-validation" with this command: npx skills add giuseppe-trisciuoglio/developer-kit/giuseppe-trisciuoglio-developer-kit-unit-test-bean-validation

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&#x3C;ConstraintViolation&#x3C;User>> violations = validator.validate(user);

assertThat(violations).isEmpty();

}

@Test void shouldFailValidationWhenNameIsNull() { User user = new User(null, "alice@example.com", 25);

Set&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;Contact>> violations = validator.validate(contact);

assertThat(violations).isEmpty();

}

@Test void shouldRejectInvalidPhoneNumberFormat() { Contact contact = new Contact("Alice", "5551234567"); // No dashes

Set&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;Contact>> violations = validator.validate(contact);

assertThat(violations).isNotEmpty();

}

@Test void shouldAllowNullPhoneNumber() { Contact contact = new Contact("Alice", null);

Set&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;ChangePasswordRequest>> violations = validator.validate(request);

assertThat(violations).isEmpty();

}

@Test void shouldFailWhenPasswordsDoNotMatch() { ChangePasswordRequest request = new ChangePasswordRequest("oldPass", "newPass123", "differentPass");

Set&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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&#x3C;ConstraintViolation&#x3C;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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

shadcn-ui

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

tailwind-css-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

react-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

typescript-docs

No summary provided by upstream source.

Repository SourceNeeds Review