Tester la validation et les erreurs
Mauvaises entrées, champs manquants, mauvais types, et à quoi ressemble une bonne erreur.
La leçon précédente envoyait de bonnes données et vérifiait qu’elles étaient stockées. Celle-ci envoie de mauvaises données et vérifie qu’elles sont refusées. Dans la plupart des projets, c’est là que se trouvent les bugs. Les développeurs écrivent d’abord le cas nominal, le testent à la main pendant qu’ils le construisent, puis manquent de temps avant d’avoir réfléchi à ce qu’un client pourrait envoyer d’autre.
Un test de validation a deux volets. Le serveur doit refuser la requête avec le bon statut, et il ne doit rien stocker. Ensuite, la qualité du message d’erreur compte, car un autre développeur le lira à un moment donné en essayant de comprendre ce qu’il a fait de travers.
Un formulaire avec des règles
POST /api/validation/registerconnectez-vous pour voir est un point d’accès d’inscription avec des règles écrites. La page de référence les liste : le nom d’utilisateur fait de 3 à 20 lettres, chiffres ou tirets bas, l’âge est un entier de 18 à 120, le pays est l’un de neuf codes, et ainsi de suite. Rien n’est stocké, vous pouvez donc envoyer le même corps autant de fois que vous voulez. Une requête valide :
/api/validation/register{
"username": "ava_t",
"email": "ava@example.com",
"password": "Str0ng!Pass",
"confirmPassword": "Str0ng!Pass",
"age": 29,
"country": "us",
"phone": "+15550101",
"acceptTerms": true
}HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"message": "User registered successfully",
"data": {
"id": 1001,
"username": "ava_t",
"email": "ava@example.com",
"age": 29,
"country": "US",
"createdAt": "2026-09-30T18:32:12Z"
}
}Remarquez que "us" est revenu en "US". Le serveur a normalisé la valeur au lieu de la refuser. C’est très bien, mais c’est un comportement, et un test devrait le figer pour que personne ne le change par accident.
Enfreignez maintenant presque toutes les règles à la fois :
/api/validation/register{
"username": "ab",
"email": "ava@example",
"password": "password",
"confirmPassword": "Password",
"age": 17,
"country": "XX",
"acceptTerms": false
}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://example.com/problems/validation-error",
"title": "One or more fields failed validation.",
"status": 422,
"detail": "7 field(s) failed validation.",
"instance": "/api/validation/register",
"errors": {
"username": ["Username must be 3-20 characters long."],
"email": ["Email must be a valid email address."],
"password": [
"Password must contain an uppercase letter.",
"Password must contain a digit.",
"Password must contain a special character."
],
"confirmPassword": ["Passwords do not match."],
"age": ["Age must be between 18 and 120."],
"country": ["Country must be one of: US, GB, IN, DE, FR, JP, CA, AU, BR."],
"acceptTerms": ["You must accept the terms and conditions."]
}
}C’est une bonne réponse d’erreur, et il vaut la peine de voir pourquoi, pour reconnaître une mauvaise plus tard.
- Elle signale tous les problèmes d’un coup. Un client qui corrige le formulaire n’a pas à l’envoyer sept fois pour trouver sept erreurs.
- Chaque erreur est rangée sous le nom de champ envoyé par le client : une interface peut donc l’afficher à côté du bon champ.
- Les messages énoncent la règle ("between 18 and 120") au lieu de dire que la valeur est fausse en laissant le client deviner pourquoi.
- Elle suit la RFC 7807 (mise à jour par la RFC 9457), un format standard pour les erreurs HTTP avec les champs
type,title,status,detailetinstance, et le type de médiaapplication/problem+json. Inutile d’exiger ce format, mais si une API prétend l’utiliser, vérifiez que les champs sont présents et que lestatusdu corps correspond au vrai code de statut.
Quoi envoyer
Vous n’essaierez pas tout sur chaque champ. Passez chaque champ en revue et demandez-vous lesquels de ces cas pourraient s’appliquer :
| Type d’entrée | Exemples |
|---|---|
| Absent | Omettez complètement le champ. Envoyez-le à null. Envoyez une chaîne vide. Ces cas peuvent se comporter différemment. |
| Mauvais type | "29" pour un nombre, 29 pour une chaîne, true pour une chaîne, un objet là où une liste est attendue |
| Limites | Le minimum et le maximum exacts, un de chaque côté, zéro, des nombres négatifs, un décimal là où un entier est attendu |
| Longueur | Vide, un caractère, la longueur maximale, un de plus, et quelque chose de très long (10 000 caractères) |
| Format | Des e-mails, dates, numéros de téléphone, URL et codes presque corrects |
| Espaces et casse | Des espaces en tête, un titre fait seulement d’espaces, Admin quand admin est déjà pris |
| Champs inconnus | Un nom de champ mal orthographié, un champ en trop, un champ que le client ne devrait pas pouvoir fixer |
| Le corps lui-même | Pas de corps, du JSON mal formé, un tableau au lieu d’un objet, le mauvais Content-Type |
Les limites du point d’accès d’inscription
La règle d’âge va de 18 à 120 : les valeurs intéressantes sont donc 17, 18, 120 et 121. Sur cette API, 18 et 120 sont acceptés, et 17 et 121 reçoivent "Age must be between 18 and 120." Une valeur de 18.5reçoit un autre message, "Age must be an integer.", tout comme la chaîne "29". Certaines API convertissent "29" en 29 sans rien dire. Aucun des deux comportements n’est faux en soi, mais les clients finiront par dépendre de celui qui sera mis en production.
Le nom d’utilisateur fait de 3 à 20 caractères : abc et un nom de 20 lettres passent, un nom de 21 lettres échoue. Un nom contenant une espace est refusé avec un message sur les caractères autorisés. Le nom déjà pris admin est refusé, tout comme Admin, ce qui vous indique que le contrôle d’unicité ignore la casse. Sinon, deux utilisateurs pourraient se retrouver avec des noms identiques à l’œil presque partout.
Le contrôle d’e-mail est le capharnaüm habituel. ava@example, ava@@example.com et ava.example.com sont refusés, tout comme " ava@example.com" avec une espace en tête. ava+qa@example.com et AVA@EXAMPLE.COM sont acceptés, ce qui est juste. a@b.c est aussi accepté. La vraie validation des e-mails est un long débat que personne ne gagne : le test porte donc moins sur la règle parfaite que sur les cas qui comptent pour votre produit, comme les adresses avec un plus, les majuscules, et le fait que les espaces autour soient supprimées ou refusées.
Vide n’est pas la même chose qu’absent
Envoyez {}et chaque champ obligatoire est signalé comme "is required". Envoyez les mêmes champs avec des valeurs null et vous obtenez le même résultat. C’est cohérent, et c’est bien. Sur /api/Booksconnectez-vous pour voir, un titre de trois espaces est aussi traité comme absent. Beaucoup d’API vérifient seulement qu’une chaîne existe : un titre fait d’espaces, ou une chaîne vide, passe alors sans encombre et réapparaît plus tard comme une ligne vide dans le tableau de quelqu’un.
Les limites de longueur manquent souvent complètement. /api/Booksconnectez-vous pour voir a accepté un titre de 5 000 caractères avec un 201. Il n’y a pas de limite documentée, donc c’est peut-être voulu, mais c’est le genre de chose avec laquelle une colonne de base de données ou une mise en page finira par ne pas être d’accord. Demandez quelle devrait être la limite, et testez un caractère de chaque côté.
Les champs inconnus
Quand un client envoie un champ que le serveur ne connaît pas, le serveur peut l’ignorer ou le refuser. Dans la leçon sur le CRUD, /api/Booksconnectez-vous pour voir a ignoré un champ year mal orthographié et stocké l’année 0. /api/validation/strictconnectez-vous pour voir, lui, refuse :
curl -X POST https://api.testingapis.com/api/validation/strict \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "email": "a@b.co", "quantity": 2, "discount": 50}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://example.com/problems/unknown-fields",
"title": "Request body contains unknown fields.",
"status": 400,
"detail": "Allowed fields: name, email, quantity (names are case-sensitive).",
"instance": "/api/validation/strict",
"unknownFields": ["discount"]
}Ici, les noms de champs sont sensibles à la casse : Name au lieu de name est donc aussi un champ inconnu. Refuser est plus sûr, puisque les fautes de frappe apparaissent tout de suite. Ignorer est plus accommodant quand une API évolue et que d’anciens clients envoient des champs supprimés. Votre travail consiste à découvrir quelle approche l’API utilise, et si elle l’applique de façon cohérente d’un point d’accès à l’autre.
Il existe un type de champ inconnu qui relève de la sécurité et non du style : un champ qui existe sur l’enregistrement mais que le client n’est pas censé fixer, comme role, isAdmin, le price d’une commande, ou l’id. Si le serveur recopie chaque champ reçu sur l’enregistrement, un utilisateur peut se promouvoir lui-même. La leçon sur la sécurité appelle cela l’assignation de masse.
Les corps cassés
Testez ensuite la requête dans son ensemble. Voici de vraies réponses du point d’accès d’inscription :
# No body at all
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"":["A non-empty request body is required."]},"traceId":"00-fc01be6d..."}
# {"username":"ava_t",} (trailing comma)
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"$":["The JSON object contains a trailing comma at the end which is not supported in this mode. Change the reader options. Path: $ | LineNumber: 0 | BytePositionInLine: 20."]},"traceId":"00-e13ac011..."}
# [] instead of an object
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"status":400,"error":"Bad Request","message":"Request body must be a JSON object."}
# Valid JSON sent as Content-Type: text/plain
HTTP/1.1 415 Unsupported Media Type
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-4cadc1e1..."}Les quatre sont gérées, et le 415 pour le mauvais Content-Type est exactement ce qu’il faut. Mais regardez les formes. Trois réponses sont du problem+json avec une table errors. Le cas du tableau renvoie un tout autre format, {status, error, message}, avec un type de contenu JSON ordinaire. Un client qui analyse les erreurs d’une façon cassera sur l’autre. Cela arrive quand certaines erreurs viennent du framework et d’autres du code de l’application, et c’est l’un des constats les plus fréquents du test d’API. Rassemblez les formes d’erreur que vous croisez dans une API et mettez-les côte à côte.
Ce qu’une erreur ne devrait pas contenir
Voici /api/Booksconnectez-vous pour voir qui reçoit une chaîne là où il attend un nombre :
curl -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{"title": "x", "author": "y", "pages": "many"}'
HTTP/1.1 400 Bad Request
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"item": ["The item field is required."],
"$.pages": ["The JSON value could not be converted to System.Int32. Path: $.pages | LineNumber: 0 | BytePositionInLine: 40."]
},
"traceId": "00-d027cc55a2925746a1bf93cc96d2306d-02234fa55d940604-00"
}Le statut est bon. Le contenu pose deux problèmes. Le message mentionne System.Int32, ce qui apprend à un tiers que le serveur tourne sous .NET et laisse au client un nom de type au lieu de "pages doit être un nombre entier". Et l’erreur supplémentaire "The item field is required." fait référence à un nom de paramètre interne, pas à quelque chose que le client a envoyé. Rien de dangereux ici, mais sur un vrai produit, vous signaleriez les deux.
Ce qui ne doit jamais apparaître dans une erreur : des traces de pile, des fragments de SQL, des chemins de fichiers, des noms d’hôtes internes et des valeurs de secrets. Un traceId, en revanche, est utile : il permet au support de retrouver le journal serveur correspondant sans rien exposer.
Quel code de statut
Les équipes débattent de 400 contre 422. Un partage courant : 400 pour un corps que le serveur n’a pas pu lire (JSON mal formé, mauvais types) et 422 pour un corps lisible qui enfreint une règle (âge 17). /api/validation/registerconnectez-vous pour voir renvoie 400 seulement quand il ne peut pas du tout analyser le corps, et 422 pour tout le reste, y compris une chaîne à la place de l’âge. /api/validation/annotationsconnectez-vous pour voir utilise 400 pour tout. Les deux approches sont acceptables si elles sont cohérentes et documentées, et deux points d’accès de la même API qui font différemment méritent une question.
| Statut | Ce qu’il signifie pour une mauvaise entrée |
|---|---|
| 400 Bad Request | La requête est mal formée ou invalide. Le client doit la modifier. |
| 415 Unsupported Media Type | Le Content-Type ne fait pas partie de ceux que le point d’accès accepte. |
| 422 Unprocessable Content | Le corps a pu être analysé, mais les valeurs enfreignent une règle. |
| 409 Conflict | Valide en soi, mais en conflit avec l’état actuel, comme un doublon. (Le point d’accès d’inscription utilise 422 pour un nom d’utilisateur déjà pris.) |
| 500 Internal Server Error | Jamais correct pour une mauvaise entrée. Le serveur a planté sur quelque chose qu’il aurait dû refuser. |
La dernière ligne est la seule règle sans exception. Si une entrée que vous envoyez produit un 500, vous avez trouvé un bug, aussi étrange que soit l’entrée. Le serveur devait la refuser, et il est tombé à la place. Vous pouvez voir à quoi ressemble un 500 bien formé sur /api/validation/problem/internalconnectez-vous pour voir, et les autres documents de problème d’exemple sont listés ici :
/api/validation/problemExercice
Testez POST /api/validation/annotationsconnectez-vous pour voir. Son exemple valide se trouve sur la page de référence. Il utilise la validation intégrée du framework au lieu de règles écrites à la main.
- Trouvez les limites de
username,ageettags. - Essayez trois numéros de téléphone et sites web invalides, et deux valides.
- Envoyez un corps invalide à
/annotationset à/registeret comparez les réponses d’erreur. Quel statut chacun utilise-t-il ? Comment les noms de champs danserrorss’écrivent-ils par rapport à ceux que vous avez envoyés ? - Envoyez une chaîne là où
ageattend un nombre. Le message laisse-t-il fuiter quelque chose ?
Rédigez les différences entre les deux points d’accès comme si vous les signaliez à un développeur : ce que vous avez envoyé, ce qui est revenu, et ce que vous attendiez.