Testing custom Spring validators without guesswork

Validation often looks like a small guardrail until a business rule must survive a REST request, an event consumer and a batch job. Testing custom Spring validators and constraint annotations is therefore more than proving that bad input is rejected. The annotation, its message and its failure point need to form a stable contract that developers can trust without starting the whole application.

Useful coverage moves from the fastest unit boundary to a real HTTP request. Australian examples expose practical edges: mobile numbers may arrive as 04..., +61 4... or spaced text; addresses combine state names with four-digit postcodes; and teams in Sydney, Melbourne, Brisbane and Perth often work across daylight-saving boundaries. All fixtures remain synthetic because realistic-looking data can still be personal data.

Why validation needs its own test boundary

A custom validator has two separate obligations: accept inputs that satisfy the rule and reject those that do not, while leaving the surrounding application free to decide how to report failure. Keeping those obligations separate prevents a 400 response from masking a broken validator. It also reveals whether a defect belongs in parsing, constraint binding or error handling.

Long-lived Spring estates remain common in Australian enterprise software, particularly around Sydney and Melbourne, where banks, logistics firms and consultancies maintain systems against integration timelines rather than short-lived campaigns. Incremental contract tests fit that reality because they strengthen an existing service without forcing a framework rewrite. In an Australian team, a plain review note such as “this value is invalid” should translate into an executable example, not a vague request to tighten validation.

Designing a precise constraint contract

Start with the smallest contract that consumers can understand. This example marks a field as an Australian mobile number and supplies the three metadata elements used by Jakarta Bean Validation: a message, validation groups and a payload type.

@Documented
@Constraint(validatedBy = AustralianMobileValidator.class)
@Target({ FIELD, PARAMETER, METHOD, ANNOTATION_TYPE, RECORD_COMPONENT })
@Retention(RUNTIME)
public @interface AustralianMobile {
    String message() default "must be a valid Australian mobile number";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

On Spring Boot 3 and Spring Framework 6, these types come from jakarta.validation; older Boot 2 applications use javax.validation. That package difference is a common source of startup failures or constraints that appear to do nothing. Decide explicitly whether null is valid: returning true for null allows a field to be absent, while pairing the custom annotation with @NotNull makes presence part of the contract. Keep groups meaningful, such as Create and Update, because an annotation attached only to the default group is ignored when another group is requested.

Implementing validation without hidden assumptions

The validator should be deterministic, side-effect free and independent of HTTP. Normalise formatting before matching, but do not silently repair arbitrary content.

public final class AustralianMobileValidator
        implements ConstraintValidator<AustralianMobile, String> {

    private static final Pattern MOBILE =
            Pattern.compile("(?:\\+?61|0)4\\d{8}");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return true;
        }
        String compact = value.trim().replaceAll("[ ()-]", "");
        return MOBILE.matcher(compact).matches();
    }
}

This implementation accepts 0400 123 456, +61 400 123 456 and 61400123456, but rejects letters, shortened numbers and unsupported prefixes such as 05. The accepted forms belong in the contract rather than in a developer’s memory. If the rule needs database state, customer type or another field, move it to a class-level validator or service-level policy; a field validator should not perform network calls. Cross-field failures should use ConstraintValidatorContext to add a precise property path instead of emitting several misleading field errors.

Comparing ways to enforce a rule

A reusable constraint is not automatically the best mechanism for every rule. The useful comparison is based on where the decision belongs and how consistently it must be enforced across transport layers.

Approach Best fit Main advantage Main risk
Built-in constraint Standard checks such as @NotNull, @Size and @Email Small, familiar and well-supported Cannot express a domain-specific rule cleanly
Custom constraint annotation A reusable field rule across DTOs and method parameters Keeps the rule declarative and close to the data Incorrect groups or null handling can surprise callers
Programmatic validator A short or rapidly changing rule Quick to implement and flexible Scatters checks through services and weakens message consistency
Service-level check A rule that depends on database or workflow state Can consult live business context Adds I/O and is harder to isolate

Built-ins are hard to beat for @NotBlank, @Size and @Email. A custom annotation wins when one declarative rule must appear on several DTOs, records and validated method parameters. Programmatic validation suits a short rule that changes often, while a service check is better when the answer depends on live state. Choose one source of truth: duplicating a check in a controller, consumer and service makes inconsistent failure messages almost inevitable.

Testing the validator in isolation

Unit tests should exercise the rule through a Jakarta Validator, not by calling isValid directly. Build it with Validation.buildDefaultValidatorFactory().getValidator(), then assert violations and property paths.

@ParameterizedTest
@ValueSource(strings = {
        "0400123456", "0400 123 456", "+61 400 123 456"
})
void acceptsSupportedAustralianMobileNumbers(String mobile) {
    SignUpRequest request = new SignUpRequest("Sam", mobile);

    assertThat(validator.validate(request))
            .noneMatch(v -> v.getPropertyPath().toString().equals("mobile"));
}

@ParameterizedTest
@ValueSource(strings = {
        "400123456", "0500123456", "+614001234567"
})
void rejectsUnsupportedAustralianMobileNumbers(String mobile) {
    SignUpRequest request = new SignUpRequest("Sam", mobile);

    assertThat(validator.validate(request))
            .extracting(v -> v.getPropertyPath().toString())
            .contains("mobile");
}

Include null, blank, whitespace, punctuation and maximum-length cases. A null value may pass the custom rule while failing a separate @NotNull constraint, so test the combined object rather than either annotation in isolation. Exercise named groups as well, especially when creation and update requests accept different fields. Assert the property path and stable message template, but avoid depending on the exact order of violations unless order is explicitly part of the API contract. Keep all examples fictitious, even when they resemble a real mobile number.

Exercising the Spring request boundary

At the Spring boundary, add a small integration test with MockMvc or WebTestClient. Post JSON containing an unsupported mobile number and expect 400 Bad Request; post a valid value and expect the intended success response. This catches a missing @Valid, incorrect Jackson binding and an exception handler that leaks internals. Assert the stable error field when the API promises one, but avoid coupling the test to an entire stack trace.

REST is only one entry point. The same DTO may be deserialised from a message queue, and an asynchronous consumer has no HTTP response to convert a validation failure into a 400. Test that path separately and decide whether the event is rejected, retried or sent to a dead-letter queue. For method-level constraints, configure Spring’s method validation explicitly and assert the expected ConstraintViolationException or mapped response. A @WebMvcTest or focused integration slice is usually enough; the full application context is not required for every rule.

Embedding validation in continuous delivery

Run the isolated validator suite as a fast Maven or Gradle task on every pull request, then run MVC and asynchronous integration checks on the main branch or overnight. Keep fixtures deterministic, including timezone-sensitive cases. Australian teams span Australian Eastern Standard Time, Australian Eastern Daylight Time and Australian Central Standard Time, so a date-related constraint needs tests near daylight-saving transitions as well as UTC execution. Wall-clock assumptions belong in dedicated boundary tests, not in a rule about a mobile number.

Validation often handles names, phone numbers and addresses, all of which may be personal information. Australian teams should account for the Privacy Act and OAIC guidance, avoid committing production exports, and keep captured request bodies out of logs unless they are deliberately sanitised. Before reusing a payload in an article, check the site policy notices so published examples do not expose information that the site should not carry. Fixture generation should therefore use invented records, stable placeholders and clearly fictitious identifiers.

The next concrete step is to add a failing JUnit 5 parameterized test covering 0400 123 456 and 0500 123 456 before implementing the custom validator.