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:

R ( e J q S u O e N s ) t R E e r ( t r B u o B e r r o a n u n s r n e d V 4 s a a 0 p r l 0 o y i n d s . e ) T ( h R ( r o I o l n w l D v s b o a a m r E c a i x k i a c ) n n e t p s t ) i o n C R a e ( t t D c u P B h r e e n r C s s s o i n V 4 s s i 0 t t o 9 e r l n a a C c i t o e n i n t o f ) n l i c t
  1. Boundary: If validation fails, return HTTP 400 immediately.
  2. Domain: If validation fails, throw an exception that triggers a transaction rollback.
  3. 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 #


What’s Next #

In the next article, we’ll explore practical refactoring patterns for existing codebases. We’ll see how to: