The tools of Validation: Testing, Errors and Organization
As a quick recap, the first post in the series examined the problems with scattered validation logic, while the second post defined the three layers of validation: Boundary, Domain, and Persistence. The third talked about the tools of validation, how to validate data in each layer, and how to create custom validators.
In this post, we will continue to explore how to test validation logic, return structured API error responses, and organize validation code in a Spring Boot project, because writing a constraint is only half the job. You also need to know that it actually rejects bad input, returns useful information to API consumers, and keeps all of this organized as the codebase grows.
Testing validation logic #
Being able to test validators is crucial for long-term maintainability. Here’s how to test each layer:
Unit tests for pure validators #
Start with the happy path — a set of known-valid inputs:
@ParameterizedTest
@ValueSource(strings = {
"189005129813",
"189005159802",
"189005199808"
})
void testSwedishPersonalNumberValidation(String input) {
var validator = new SwedishPersonalNumberValidator();
var context = mock(ConstraintValidatorContext.class);
boolean isValid = validator.isValid(input, context);
Assertions.assertTrue(isValid);
}
But a validator test that only tests the happy-path is not a good enough test. I could replace the code of the validator
with return true and the test above would still pass. So what we need is a test that makes sure the validator returns
false when we send in invalid data:
@ParameterizedTest
@ValueSource(strings = {
"189005129810", // Correct number is ...9813; last digit deliberately wrong
"not-a-number",
"199913019876" // Invalid date component (month 13)
})
void testSwedishPersonalNumberRejectsInvalidInput(String input) {
var validator = new SwedishPersonalNumberValidator();
var context = mock(ConstraintValidatorContext.class);
boolean isValid = validator.isValid(input, context);
Assertions.assertFalse(isValid);
}
Domain validation tests #
Domain validation tests should be pure unit tests without Spring dependencies. Here’s the Weight/Shipment pair
from the previous post — note there are two things worth testing separately: the normalization behavior (null/zero
collapsing to the minimum) and the rejection behavior (negative values throwing). A validator that only tests the
happy path could hide a broken default just as easily as it could hide a broken rejection.
class WeightTest {
@Test
void testNullWeightDefaultsToMinimum() {
var weight = new Weight(null);
Assertions.assertEquals(BigDecimal.ONE, weight.value());
}
@Test
void testZeroWeightDefaultsToMinimum() {
var weight = new Weight(BigDecimal.ZERO);
Assertions.assertEquals(BigDecimal.ONE, weight.value());
}
@Test
void testNegativeWeightThrowsException() {
Assertions.assertThrows(IllegalArgumentException.class, () -> new Weight(new BigDecimal("-3")));
}
@Test
void testPositiveWeightIsPreserved() {
var weight = new Weight(new BigDecimal("42.5"));
Assertions.assertEquals(new BigDecimal("42.5"), weight.value());
}
}
class ShipmentTest {
@Test
void testShipmentWithBlankIdThrowsException() {
Assertions.assertThrows(IllegalArgumentException.class,
() -> new Shipment("", new Weight(BigDecimal.TEN)));
}
@Test
void testValidShipmentIsCreated() {
var shipment = new Shipment("shp-1", new Weight(new BigDecimal("10")));
Assertions.assertNotNull(shipment);
Assertions.assertEquals(new BigDecimal("10"), shipment.weight().value());
}
}
API boundary tests #
@WebMvcTest(controllers = CustomerController.class)
class CustomerControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void testCreateCustomerWithInvalidEmail() throws Exception {
String invalidJson = """
{
"name": "John Doe",
"email": "invalid-email",
"personalNumber": "189005129813"
}
""";
mockMvc.perform(post("/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("email"));
}
}
Database constraint tests #
Assuming we have an entity with the following structure:
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotNull // Bean Validation
@Column(nullable = false, unique = true) // DB Constraint
private String email;
@Column(nullable = false) // DB Constraint
private LocalDate birthDate;
// Getters...
}
A test to make sure we get a DataIntegrityViolationException when trying to create a customer with an existing email,
would look like this:
@DataJpaTest
class UserRepositoryTest {
@Autowired
CustomerRepository repository;
@Autowired
EntityManager entityManager;
@Test
void shouldSaveTwoCustomersWithDifferentEmails() {
assertDoesNotThrow(() -> {
repository.save(new Customer("[email protected]"));
repository.save(new Customer("[email protected]"));
// Force SQL execution so constraint violations would surface
entityManager.flush();
});
}
@Test
void shouldFailWhenEmailIsDuplicated() {
repository.save(new Customer("[email protected]"));
repository.save(new Customer("[email protected]"));
assertThrows(DataIntegrityViolationException.class, () -> entityManager.flush());
}
}
Validation flows across layers #
Here’s how validation flows through a request:
- Boundary: If validation fails, return HTTP 400 immediately.
- Domain: If validation fails, throw an exception that triggers a transaction rollback.
- Persistence: If validation fails (e.g., unique constraint violation), catch and map to a 409 Conflict.
API error codes and structured responses #
For any API, returning structured responses with stable error codes is essential:
{
"type": "validation_error",
"errors": [
{
"field": "email",
"code": "ERR_INVALID_EMAIL",
"message": "must be a well-formed email address"
}
]
}
Global Exception Handler #
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex) {
List<ValidationFieldError> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> new ValidationFieldError(
fieldError.getField(),
fieldError.getCode(),
fieldError.getDefaultMessage()
))
.toList();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ValidationErrorResponse("validation_error", errors));
}
}
Package Organization for Validation #
Organize validation code by feature + layer, rather than one large generic validation package:
com.example.customer
api
dto
CreateCustomerRequest.java
validation
SwedishPersonalNumberValidator.java
ValidSwedishPersonalNumber.java
application
service
CustomerService.java
domain
model
Customer.java
rule
CustomerEligibilityRule.java
persistence
entity
Customer.java
This keeps validation logic close to where it’s used while allowing reuse when appropriate.
Key Takeaways #
- Jakarta Bean Validation is the foundation for DTO validation with built-in constraints like
@NotNull,@NotBlank,@Size, etc. @Validtriggers cascading validation on nested objects, while@Validatedenables method-level validation.- Custom constraints are essential for domain-specific validation like Swedish personal numbers or cross-field validation.
- Testing validation requires different strategies: parameterized tests for pure validators, and @WebMvcTest slice tests for API boundaries.
- Package organization should be by feature + layer, not one large generic validation package.
What’s Next #
In the next article, we’ll explore practical refactoring patterns for existing codebases. We’ll see how to:
- Identify and fix the “silent repair” antipatterns in
DbHandlerand similar classes - Refactor mappers to remove business logic
- Introduce value objects gradually without breaking existing code
- Add validation layers incrementally to legacy systems