home / skills / giuseppe-trisciuoglio / developer-kit / unit-test-bean-validation

This skill helps you unit-test Jakarta Bean Validation with patterns and examples for built-in constraints and custom validators.

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill unit-test-bean-validation

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
15.0 KB
---
name: unit-test-bean-validation
description: Provides patterns for unit testing Jakarta Bean Validation (@Valid, @NotNull, @Min, @Max, etc.) with custom validators and constraint violations. Validates 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
allowed-tools: Read, Write, Bash, Glob, Grep
---

# 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

1. **Add validation dependencies**: Include jakarta.validation-api and hibernate-validator in your test classpath
2. **Create a Validator instance**: Use `Validation.buildDefaultValidatorFactory().getValidator()` in @BeforeEach
3. **Test valid scenarios**: Always test that valid objects pass validation without violations
4. **Test each constraint separately**: Create focused tests for individual validation rules
5. **Extract violation details**: Use assertions to verify property path, message, and invalid value
6. **Test custom validators**: Write dedicated tests for each custom constraint implementation
7. **Use parameterized tests**: Apply @ParameterizedTest for testing multiple invalid inputs efficiently
8. **Test validation groups**: Verify conditional validation based on validation groups

## Examples

## 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", "[email protected]", 25);
    
    Set<ConstraintViolation<User>> violations = validator.validate(user);
    
    assertThat(violations).isEmpty();
  }

  @Test
  void shouldFailValidationWhenNameIsNull() {
    User user = new User(null, "[email protected]", 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("   ", "[email protected]");
    
    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", "[email protected]", -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", "[email protected]", 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 = {
    "[email protected]",
    "[email protected]",
    "[email protected]"
  })
  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 [email protected]"
  })
  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](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)

Overview

This skill provides concise, reusable patterns for unit testing Jakarta Bean Validation constraints and custom validators without loading a Spring context. It shows how to create a Validator, assert constraint violations, test cross-field and group-based validation, and cover edge cases using JUnit 5 and parameterized tests. Use it to verify data integrity and ensure validation rules behave as expected in isolation.

How this skill works

The skill demonstrates building a Validator via Validation.buildDefaultValidatorFactory().getValidator() and using validator.validate(...) to collect ConstraintViolation sets. It extracts property paths, messages, and invalid values for precise assertions. Examples include built-in annotations (@NotNull, @Email, @Min/@Max, @Size), custom ConstraintValidator implementations, cross-field constraints, validation groups, and parameterized test scenarios.

When to use it

  • You need fast validation checks without booting Spring or the full application context
  • Testing built-in annotations like @NotNull, @Email, @Min, @Max, @Size
  • Verifying and unit-testing custom @Constraint validators and their error messages
  • Testing cross-field rules such as password confirmation or interdependent fields
  • Validating conditional rules using validation groups (create vs update)

Best practices

  • Add jakarta.validation-api and hibernate-validator to the test classpath and use AssertJ for expressive assertions
  • Create a Validator in @BeforeEach via Validation.buildDefaultValidatorFactory() for isolation or reuse a thread-safe instance if appropriate
  • Write focused tests: one constraint per test and include both valid and invalid cases
  • Assert violation details: property path, message text, and invalid value for precise feedback
  • Use @ParameterizedTest to cover many input permutations and test edge cases (null, empty, whitespace)

Example use cases

  • Unit-test DTO validation for incoming API payloads before wiring into controllers
  • Verify a custom phone number validator rejects incorrect formats and accepts null when combined with @NotNull
  • Test cross-field constraint ensuring newPassword equals confirmPassword on change-password objects
  • Validate conditional rules with groups so required fields are enforced only on create operations
  • Run parameterized email validation tests covering valid and invalid address formats

FAQ

Do I need Spring to run these validation tests?

No. The examples use Jakarta Validator directly so tests run fast without starting Spring or loading the application context.

Should custom validators accept null values?

Custom ConstraintValidator implementations should typically treat null as valid and rely on @NotNull when a value is required.