1. Validation à la frontière de la couche web
Lorsque vous écrivez une API pour la première fois, vous êtes tenté de penser : « Eh bien, si le client envoie quelque chose d’étrange, nous nous en occuperons dans le service ». C’est naturel, mais dans un backend réel, cela se transforme rapidement en chaos : le service commence à vérifier les chaînes vides, null et les longueurs de champs, et le contrôleur devient simplement un « tuyau ». La validation à la frontière, c’est comme un tourniquet dans le métro : il ne décide pas où vous allez, mais il doit écarter la personne sans billet.
Dans ContentHub, la couche web est l’endroit où nous devons filtrer les requêtes du type « créer un article, mais sans titre » ou « titre de 5000 caractères parce que je le sens ainsi ». Ces requêtes ne doivent pas arriver jusqu’à la couche service du tout. Non pas parce que le service est faible et s’offenserait, mais parce que le service a un autre travail : les règles métier (statuts, workflow, contraintes de transitions), et non la vérification qu’une chaîne n’est pas vide.
Il y a une autre raison pragmatique : la validation sur le DTO rend le comportement de l’API prévisible. Vous obtenez un mécanisme unique, des erreurs uniques et un style unique « comment le client peut comprendre ce qu’il a mal fait ». Et cela s’intègre parfaitement dans notre cours : nous capturons ce comportement avec des tests MVC, car c’est précisément cela qui fait partie du contrat de l’API.
Imaginez que vous êtes le client (application mobile, frontend, autre service). Peu importe le niveau auquel vous avez vérifié le champ title. Ce qui compte, c’est que l’API réponde de manière stable : « 400, voici la liste des violations, voici les champs à corriger ». C’est cette stabilité que le test doit protéger.
2. Bean Validation dans Spring MVC : où la requête casse
Pour écrire de bons tests, il est important de comprendre à quel stade Spring parvient à valider les données. Nous n’allons pas aujourd’hui fouiller dans « les entrailles de Spring MVC jusqu’au dernier filtre », mais vous avez besoin d’un modèle minimal de traitement de la requête — sinon les cas négatifs se mélangent en une bouillie du genre « ben c’est juste 400 quoi ».
Avec Bean Validation, la logique est la suivante : d’abord Spring essaie de lire le corps de la requête et de construire le DTO (désérialisation JSON → objet Java). Si cela réussit, alors seulement Bean Validation s’active et vérifie les annotations comme @NotBlank et @Size. Si le DTO viole les contraintes, un validation failure se produit : la requête est lue, le DTO est créé, mais le DTO est « mauvais ».
Il est pratique de garder cette idée comme un petit schéma. Voici une « ligne de vie » simplifiée de la requête pour notre POST /api/editor/articles :
flowchart TD
A["HTTP request
Content-Type: application/json"] --> B["JSON -> DTO binding
(Jackson)"]
B -->|succès| C["@Valid déclenche Bean Validation"]
B -->|erreur de lecture| X["pas notre cas aujourd'hui
(corps non lu)"]
C -->|DTO valide| D["Méthode du contrôleur invoquée"]
C -->|DTO invalide| E["MethodArgumentNotValidException"]
D --> F["Service est appelé"]
E --> G["Réponse d'erreur (ApiProblem + violations)"]
Aujourd’hui, nous travaillons strictement avec la branche où le JSON est correct, on peut construire le DTO, mais le DTO viole les règles. Nous testons donc trois choses simultanément (et c’est important à dire clairement) : premièrement, le statut (généralement 400), deuxièmement, le format d’erreur (notre ApiProblem), et troisièmement, la frontière — que le service n’a pas été appelé.
Si vous vous surprenez à penser « mais pourquoi 400 et pas 422 ? », vous n’êtes pas seuls. Dans différents systèmes, on rencontre aussi 422 Unprocessable Entity, mais dans le cadre de notre projet et de la méthodologie choisie, nous maintenons une discipline simple : les violations des données d’entrée sont 400, parce que le client a envoyé une requête qui ne respecte pas les règles du formulaire d’entrée. L’important n’est pas le nombre en lui-même, mais la stabilité du contrat et la clarté de la raison.
3. Contraintes du DTO : @NotBlank et @Size
Bean Validation n’est pas une « coche qualité » magique, mais un mécanisme assez honnête : vous écrivez les contraintes sur les champs, et Spring les vérifie lorsque vous le demandez (@Valid). Si vous n’avez pas écrit de contraintes, Spring ne « devinera » rien et ne validera pas par télépathie. La première chose que nous faisons dans ContentHub est donc de déclarer les règles directement sur le request DTO.
Pour le scénario de création d’un brouillon (editor API), il est logique de fixer les exigences de base : le titre, summary, body et category sont obligatoires ; le titre ne doit pas être infini. Dans un projet pédagogique, cela ressemble à ceci :
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
class CreateArticleRequest {
// Titre obligatoire : null/""/" " — nous n'acceptons pas
@NotBlank
// On limite la longueur du titre en entrée pour ne pas traîner une « tartine » plus loin
@Size(max = 120)
private String title;
// Summary aussi obligatoire : les chaînes vides et les espaces ne sont pas considérés comme des données
@NotBlank
private String summary;
}
Oui, le DTO ici est « tronqué » pour un exemple court. Dans le vrai CreateArticleRequest, vous aurez aussi body, category, et peut-être d’autres champs. L’idée est la même : les contraintes sont à côté du champ, et non « quelque part dans le test » ou « quelque part dans le service ».
Une question très fréquente chez les débutants : « Pourquoi @NotBlank et pas @NotNull ? » Parce que @NotNull interdit null, mais autorise la chaîne vide "" et la chaîne d’espaces " ". Pour une API, ce n’est généralement pas ce que vous voulez. @NotBlank dit justement : « la chaîne doit être non null, et après trim() il doit y avoir au moins un caractère ». C’est plus proche du sens humain « remplis le champ ».
Et @Size(max=120) n’est pas une « beauté » ni « au cas où ». C’est une protection contre deux problèmes réels : le premier — l’utilisateur a accidentellement inséré un énorme texte dans le titre, et l’interface utilisateur ne l’a pas capté ; le deuxième — quelqu’un envoie délibérément des champs énormes (parfois sans méchanceté, mais parce que « on ne sait jamais si ça passe »). Limiter la longueur en entrée fait partie de la stabilité de l’API.
Pour ne pas avoir à retenir toutes les annotations par cœur, il est utile d’avoir un petit tableau — non pas comme une antisèche pour l’examen, mais comme un repère « ce qu’on met habituellement sur le request DTO » :
| Annotation | Type de champ | Signification en langage humain | Cas typique dans ContentHub |
|---|---|---|---|
| @NotNull | tout | valeur obligatoire, mais n’interdit pas les chaînes vides | champs techniques où null n’est pas autorisé |
| @NotBlank | String | la chaîne est obligatoire et ne peut être vide ou composée d'espaces | title, summary, body, category |
| @Size(min, max) | String, collections | limitation de longueur/taille | longueur du titre, limites des listes |
| @Pattern(regexp=...) | String | format de la chaîne selon une expression régulière | par exemple, si nous validions le slug en entrée |
La règle principale ici est très « ingénieur » : dans le DTO vivent les règles formelles du format d’entrée, et non les règles métier du type « un brouillon ne peut pas être publié directement ». Les règles métier, c’est le service et la logique domaine. Le DTO, c’est « le formulaire que le client remplit ».
4. @Valid dans le contrôleur : « le robinet d’arrêt » avant la couche service
Maintenant, un point important : les contraintes sur le DTO ne s’activent pas toutes seules. C’est la validation de l’argument dans la méthode du contrôleur qui les déclenche. Pour le request body, cela se fait simplement : vous ajoutez @Valid sur le paramètre @RequestBody. C’est exactement ça le « robinet d’arrêt » qui empêche la requête incorrecte d’aller plus loin.
Exemple minimal pour l’endpoint editor dans ContentHub :
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
class EditorArticleController {
// Service ici conditionnel : dans un vrai projet, il sera injecté via le constructeur
private final EditorArticleService service;
EditorArticleController(EditorArticleService service) {
this.service = service;
}
@PostMapping("/api/editor/articles")
void create(@Valid @RequestBody CreateArticleRequest request) {
// Important : on n'arrive ici que si le DTO a été assemblé avec succès et a passé la Bean Validation
service.create(request);
}
}
Si on retire @Valid, une requête avec un title vide peut très bien arriver dans la méthode du contrôleur, et ensuite vous serez obligé soit d’écrire des vérifications à la main, soit de rattraper une erreur « quelque part plus bas ». Et c’est précisément le cas où les débutants rencontrent des « tests étranges » : le test attend 400 mais reçoit 200 ou 500, parce que le DTO n’a pas été validé du tout, le service a essayé de traiter des déchets, et tout s’est déroulé de manière imprévisible.
Dans le contexte de @WebMvcTest, il est important de fixer encore une chose : si la validation a échoué, le contrôleur ne doit pas appeler le service. Et cela ne se teste pas « à l’œil », mais littéralement : le service dans la slice MVC est un mock, et nous vérifions qu’il n’y a eu aucun appel.
Une simple modèle mental est très utile ici : le contrôleur est un « traducteur » HTTP → appel de service. Si l’entrée est mauvaise, le traducteur doit s’arrêter et dire : « désolé, on n’accepte pas ça ». Il ne doit pas « transmettre le mauvais papier plus loin dans la chaîne ».
5. Validation dans @WebMvcTest : 400 et violations
Maintenant le plus intéressant : comment tester cela de manière à la fois stricte et peu fragile. Dans notre cours, nous avons déjà convenu que les erreurs de l’API ont un contrat stable ApiProblem (de style ProblemDetail avec des champs supplémentaires), et pour les validation failures, le bloc violations est particulièrement important. Dans le test, il ne suffit donc pas de voir simplement 400 : nous voulons voir que le client reçoit une « liste de problèmes » compréhensible.
Imaginons que notre API renvoie une erreur de ce genre :
{
"title": "Validation failed",
"status": 400,
"detail": "Request validation failed",
"errorCode": "VALIDATION_FAILED",
"violations": [
{ "field": "title", "message": "must not be blank" }
]
}
Vous n’êtes pas obligés de savoir aujourd’hui comment cela est assemblé à l’intérieur de @ControllerAdvice — nous en reparlerons séparément. Mais le test au niveau de la frontière web est en droit d’attendre que le contrat soit exactement celui-ci : statut + liste de violations.
Test de base : titre vide → 400 + violations
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class EditorArticleControllerWebMvcTest {
private MockMvc mvc;
@Test
void shouldReturn400WhenTitleIsBlank() throws Exception {
// Important : JSON correct (se parse), mais le DTO viole les contraintes (title blank)
String body = """
{"title":" ","summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(
post("/api/editor/articles")
// On spécifie explicitement JSON pour que le test vérifie bien la validation, et non la « devinette » des convertisseurs
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
// Contrat : pour les validation failures, on attend 400
.andExpect(status().isBadRequest())
// Et au minimum : le bloc violations doit être présent dans la réponse
.andExpect(jsonPath("$.violations[0]").exists());
}
}
Notez la subtilité : nous envoyons un JSON correct, simplement title est des espaces. C’est une pure validation failure. Nous mettons explicitement Content-Type: application/json pour que le test ne dépende pas de « ce que Spring décide par défaut ». Et nous vérifions non seulement le statut, mais aussi la présence de violations.
Si votre contrat est plus strict (par exemple, violations est toujours un tableau d’objets {field, message}), vous pouvez renforcer la vérification :
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void shouldIncludeTitleViolation() throws Exception {
// Ici title est une chaîne vide — @NotBlank doit aussi se déclencher
String body = """
{"title":"","summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(
post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest())
// Vérifions le sens : la violation concerne bien le champ title
.andExpect(jsonPath("$.violations[0].field").value("title"));
}
Ici nous fixons le sens : c’est bien le champ title qui est violé. C’est utile car le code client met souvent en évidence les champs spécifiques du formulaire.
Test « robinet d’arrêt » : le service n’est pas appelé
Dans une slice MVC, le service est généralement remplacé par un mock. Alors le test peut et doit prouver que la requête n’est pas allée plus loin.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldStopBeforeServiceWhenRequestIsInvalid() throws Exception {
// Le DTO sera invalide à cause de title=""
String body = """
{"title":"","summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(
post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest());
// Vérification clé de la frontière : le service ne doit pas être appelé du tout
then(service).shouldHaveNoInteractions();
}
Ce test est l’un des plus « précieux en termes d’utilité » de tout le sujet de la validation. Il vous protège d’une situation où quelqu’un aurait accidentellement retiré @Valid, changé la signature du contrôleur, ou reconfiguré quelque chose — et soudainement, les mauvaises requêtes commenceraient à arriver dans le service. Ce n’est pas juste de l’« esthétique », c’est une vraie assurance contre la régression.
Test paramétré sur une seule règle (sans copier-coller)
Quand la règle est la même et qu’il y a plusieurs variantes d’entrée, il est pratique d’utiliser des tests paramétrés. Par exemple, @NotBlank doit être testé avec "" et " " — et ça suffit, pas besoin de faire une olympiade du nombre d’espaces.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ParameterizedTest
@ValueSource(strings = {"", " "})
void shouldRejectBlankTitle(String title) throws Exception {
// On construit le body de la requête de sorte que seul title change (on vérifie une seule idée)
String body = """
{"title":"%s","summary":"S","body":"B","category":"JAVA"}
""".formatted(title);
mvc.perform(
post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest());
}
Ce test reste lisible car il vérifie une seule idée : « title ne doit pas être blank ». Et il ne se transforme pas en moissonneuse-batteuse qui vérifie en même temps title, summary et category.
À propos des « messages d’erreur » : pourquoi il vaut mieux ne pas les vérifier mot pour mot. On a très envie de vérifier dans le test le message "must not be blank" ou "Title is required". Parfois c’est pertinent, mais le plus souvent cela rend le test fragile. Les messages peuvent changer à cause de la localisation, des réglages du fournisseur, parce que vous avez modifié le texte dans le DTO sans changer le sens du contrat.
Il est plus fiable de fixer des choses plus structurelles : le statut, la présence de violations, le nom du champ, et le fait qu’il y ait un message (pas nécessairement le texte exact). Si on tient absolument, on peut vérifier la clé d’erreur ou un errorCode personnalisé, plutôt qu’une phrase en langage humain.
6. Validation en cascade : @Valid dans les DTO imbriqués
Tant que le DTO est plat (tous les champs au même niveau), c’est assez simple. Mais dès que vous commencez à faire une requête « comme un humain », vous avez des objets imbriqués. Par exemple, vous voulez regrouper le texte de l’article dans payload, et les métadonnées séparément. Et c’est là que se cache un bug classique sur lequel il est très facile de tomber : @Valid sur le paramètre du contrôleur ne déclenche pas automatiquement la validation de l’objet imbriqué. Pour une validation en cascade, il faut @Valid directement sur le champ de l’objet imbriqué.
Exemple — simplifié, mais réaliste :
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
class WrapperRequest {
// Point clé : sans @Valid, l'objet imbriqué NE sera PAS validé en cascade
@Valid
private NestedRequest payload;
}
class NestedRequest {
// Cette contrainte ne s'activera que si payload est annoté avec @Valid dans WrapperRequest
@NotBlank
private String value;
}
Si on enlève @Valid du champ payload, alors value à l’intérieur de NestedRequest peut être vide — et Bean Validation ne le détectera pas. Le DTO sera considéré comme valide, alors qu’il contient des déchets. C’est exactement ce « bug silencieux » qui explose plus tard dans le service.
Comment tester cela à la frontière web ? Exactement comme les cas plats : envoyer un JSON correct, mais violer une règle à l’intérieur de l’objet imbriqué, et s’attendre à un 400.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldReturn400WhenNestedValueIsBlank() throws Exception {
// JSON correct, mais payload.value viole @NotBlank
String body = """
{"payload":{"value":" "}}
""";
mvc.perform(
post("/api/editor/articles/wrapper")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest());
}
Oui, c’est un « endpoint de démo » pour l’exemple. Dans le vrai ContentHub, vous rencontrerez ce genre d’imbrication quand le DTO grandira : par exemple, un objet imbriqué pour les paramètres, une sous-structure pour le « contenu », une liste de pièces jointes, etc. Et là, la présence de @Valid sur le champ de l’objet imbriqué cesse d’être un « détail d’annotations » et devient une question : « est-ce que notre protection d’entrée fonctionne vraiment ? »
Il est utile de se rappeler une analogie simple. @Valid sur le paramètre du contrôleur, c’est comme une demande : « vérifie cet objet ». Et @Valid sur le champ, c’est comme une instruction : « et à l’intérieur aussi, vérifie tout ce qui est marqué de contraintes ». Sans la seconde, Spring dira honnêtement : « j’ai vérifié l’objet du dessus, il n’est pas null, donc tout va bien ». Et formellement, il aura raison.
7. Erreurs typiques dans les tests de validation MVC
Erreur n°1 : s’attendre à une validation sans @Valid et ensuite accuser les tests d’avoir un « comportement étrange ».
C’est le scénario le plus fréquent : les contraintes sur le DTO sont présentes, mais on a oublié @Valid sur @RequestBody, et la requête passe tranquillement dans le contrôleur. Le test reçoit soit un 200, soit échoue plus loin dans la chaîne. La solution est simple : dans le contrôleur, @Valid n’est pas une décoration, mais un interrupteur du mécanisme.
Erreur n°2 : vérifier seulement le statut 400 et ne pas fixer le contrat d’erreur.
Le statut à lui seul ne prouve pas grand-chose : on peut obtenir un 400 pour d’autres raisons. Si vous ne vérifiez pas au moins la présence de violations, vous ne protégez pas le contrat client. Un bon test négatif regarde à la fois le statut et la partie clé du corps de la réponse que le client va réellement utiliser.
Erreur n°3 : vérifier le texte du message de violation mot pour mot et ensuite s’étonner des « flaky tests » sans raison.
Les messages de contrainte peuvent différer entre environnements, être localisés, changer avec une petite modification. Si vous avez besoin de stabilité, fixez le champ (title, summary) et le fait même qu’un message existe, plutôt que la phrase exacte. Les vérifications mot à mot sont presque toujours trop fragiles.
Erreur n°4 : oublier Content-Type: application/json et tester accidentellement autre chose que ce que vous croyez.
Dans les tests MVC, les « détails HTTP » comptent beaucoup. Sans Content-Type, Spring peut choisir un autre convertisseur, interpréter le corps différemment, échouer différemment. Si vous testez la validation d’un request DTO, spécifiez explicitement application/json pour que le test vérifie bien la validation, et non un « format de requête inconnu » aléatoire.
Erreur n°5 : ne pas vérifier la frontière « le service n’est pas appelé ».
Même si l’API renvoie un 400, il est utile de prouver que la requête n’est pas partie dans le service. Cela protège contre une régression où quelqu’un aurait retiré la validation et commencé à lancer manuellement une exception dans le service (ou pire, traiter partiellement une requête invalide). then(service).shouldHaveNoInteractions() — une petite ligne qui fait souvent gagner des heures de débogage.
GO TO FULL VERSION