Back to Testing & Quality Assurance

unit-test-bean-validation

Jakarta Bean ValidationUnit TestingJavaValidationData IntegrityJUnit 5Custom ValidatorsBackend Development
282📄 MIT🕒 2026-06-15Source ↗

Install this skill

npx skills add giuseppe-trisciuoglio/developer-kit

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

This skill focuses on isolated unit testing of Jakarta Bean Validation constraints and custom annotation-based logic. By initializing a local Hibernate Validator instance within JUnit 5 tests, developers can verify data integrity rules without the overhead of booting a full application framework like Spring. It enables precise testing of constraint violations, property path accuracy, and error message generation for standard annotations like @NotNull, @Email, and @Min. The workflow centers on triggering the validator manually to collect Set collections of ConstraintViolation objects, which are then inspected using fluent assertion libraries. This approach ensures that input sanitization and business rules embedded in domain models remain correct under varying conditions, facilitating rapid feedback loops during iterative development cycles while maintaining a strictly lightweight and non-integrated testing posture.

When to Use This Skill

  • Validating DTOs for REST API inputs before service-layer processing
  • Unit testing custom business rule constraints on entity classes
  • Confirming that complex object graphs adhere to nested validation rules
  • Verifying that specific locale-based error messages are generated correctly

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • How do I unit test Jakarta bean validation constraints?
  • Show me how to verify validation errors in JUnit 5 without Spring.
  • Help me write a test for a custom @Constraint validator.
  • What is the best way to check constraint violations for a DTO?
  • How do I assert that a POJO fails validation for specific fields?

Pro Tips

  • 💡Always test both valid and invalid scenarios for each constraint, paying close attention to edge cases like boundary values for `@Min`/`@Max`.
  • 💡When testing custom validators, consider injecting mock dependencies if your validator relies on external services to keep the test truly isolated.
  • 💡Utilize parameterization in JUnit 5 to efficiently test multiple input values against the same validation rule, reducing boilerplate code.

What this skill does

  • Execute programmatic validation against POJO domain models
  • Inspect constraint violation error messages and property paths
  • Verify custom cross-field constraint validator implementations
  • Test standard Jakarta validation annotations in isolation
  • Perform boundary condition testing for numeric ranges and string sizes

When not to use it

  • Testing integration with HTTP controllers or Spring-managed web filters
  • When validation logic depends on database lookup queries

Example workflow

  1. Configure a Validator instance in a @BeforeEach JUnit method.
  2. Instantiate the target DTO with invalid field values.
  3. Invoke the validator.validate() method on the object.
  4. Capture the returned Set of ConstraintViolation objects.
  5. Use AssertJ to verify the expected field path and message.

Prerequisites

  • jakarta.validation-api dependency
  • hibernate-validator implementation
  • junit-jupiter for test execution
  • assertj-core for fluent assertions

Pitfalls & limitations

  • !Forgetting to initialize the validator instance in @BeforeEach can lead to null pointer exceptions.
  • !Testing logic that requires external context like database connections, which this approach does not support.
  • !Misinterpreting the difference between field-level and class-level validation errors in assertions.

FAQ

Do I need the Spring Framework to test these constraints?
No, you can test validation logic entirely in isolation by using the standard Hibernate Validator factory provided by Jakarta.
How do I check if my object is valid in a test?
Assert that the set returned by validator.validate() is empty, which signifies that no constraints were violated.
Can I test cross-field constraints with this method?
Yes, as long as the constraint is applied at the class level, the manual validator will correctly trigger the logic during validation.
Why should I use AssertJ with this?
AssertJ provides readable, fluent methods to extract messages or paths from the violation collection, making tests cleaner than standard JUnit assertions.

How it compares

While generic prompts might suggest full-stack integration testing, this skill emphasizes isolated, sub-millisecond execution that identifies constraint issues without booting the application context.

Source & trust

282 stars📄 MIT🕒 Updated 2026-06-15
📄 Full skill instructions — original source: giuseppe-trisciuoglio/developer-kit
# 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
<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", "[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

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

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 = {
"[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

## 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)

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/unit-test-bean-validation/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/giuseppe-trisciuoglio/developer-kit/unit-test-bean-validation/SKILL.md
  • Cursor: ~/.cursor/skills/giuseppe-trisciuoglio/developer-kit/unit-test-bean-validation/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/giuseppe-trisciuoglio/developer-kit/unit-test-bean-validation/SKILL.md

🚀 Install with CLI:
npx skills add giuseppe-trisciuoglio/developer-kit

Read the Master Guide: Mastering Agent Skills

Related Skill Units

Recommended Rules

View more rules

Recommended Workflows

View more workflows

Recommended MCP Servers

View more MCP servers

Take It Further

Maximize your productivity with these powerful resources

📋

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
📖

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid testing & quality assurance issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Testing & Quality Assurance and is published by Giuseppe Trisciuoglio, maintained in giuseppe-trisciuoglio/developer-kit.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.