Tester les handlers REST en Go : httptest et tests table-driven
Tester les handlers REST en Go avec httptest et des cas table-driven vous donne une méthode répétable pour vérifier l'auth, la validation, les codes de statut et les cas limites avant la mise en production.

Ce dont vous devez être sûr avant la mise en production
Un handler REST peut se compiler, passer une vérification manuelle rapide et quand même échouer en production. La plupart des échecs ne sont pas des problèmes de syntaxe. Ce sont des problèmes de contrat : le handler accepte ce qu’il devrait rejeter, renvoie un mauvais code de statut ou divulgue des détails dans une erreur.
Les tests manuels aident, mais il est facile de rater des cas limites et des régressions. Vous testez le chemin heureux, peut‑être une erreur évidente, puis vous passez à autre chose. Puis un petit changement dans la validation ou le middleware casse en silence un comportement que vous pensiez stable.
Le but des tests de handlers est simple : rendre les promesses du handler répétables. Cela inclut les règles d'authentification, la validation des entrées, des codes de statut prévisibles et des corps d'erreur sur lesquels les clients peuvent compter.
Le paquet httptest de Go est idéal car il vous permet d'exécuter un handler directement sans démarrer un vrai serveur. Vous construisez une requête HTTP, la passez au handler et inspectez le corps de la réponse, les en-têtes et le code de statut. Les tests restent rapides, isolés et faciles à lancer à chaque commit.
Avant la mise en production, vous devriez savoir (et non espérer) que :
- Le comportement d'auth est cohérent pour les tokens manquants, invalides et les rôles incorrects.
- Les entrées sont validées : champs obligatoires, types, plages, et (si vous l'imposez) champs inconnus.
- Les codes de statut correspondent au contrat (par exemple 401 vs 403, 400 vs 422).
- Les réponses d'erreur sont sûres et cohérentes (pas de traces de pile, même forme à chaque fois).
- Les chemins non heureux sont gérés : timeouts, échecs en aval et résultats vides.
Un endpoint “Create ticket” peut fonctionner quand vous envoyez du JSON parfait en tant qu’admin. Les tests capturent ce que vous oubliez d’essayer : un token expiré, un champ supplémentaire envoyé par le client, une priorité négative, ou la différence entre “not found” et “internal error” quand une dépendance échoue.
Définissez le contrat pour chaque endpoint
Écrivez ce que le handler promet de faire avant d’écrire les tests. Un contrat clair garde les tests ciblés et empêche qu’ils ne deviennent des suppositions sur ce que le code “voulait faire”. Cela rend aussi les refactorings plus sûrs : vous pouvez changer l’implémentation sans modifier le comportement public.
Commencez par les entrées. Soyez précis sur l’origine de chaque valeur et sur ce qui est requis. Un endpoint peut prendre un id depuis le path, limit depuis la query, un en-tête Authorization et un corps JSON. Notez les règles importantes : formats autorisés, min/max, champs obligatoires et ce qui se passe quand quelque chose manque.
Puis définissez les sorties. Ne vous contentez pas de “retourne du JSON”. Décidez à quoi ressemble le succès, quels en‑têtes importent et quelles sont les erreurs. Si les clients dépendent de codes d'erreur stables et d'une forme JSON prévisible, traitez cela comme faisant partie du contrat.
Une checklist pratique :
- Entrées : valeurs path/query, en‑têtes requis, champs JSON et règles de validation
- Sorties : code de statut, en‑têtes de réponse, forme JSON pour succès et erreur
- Effets secondaires : quelles données changent et ce qui est créé
- Dépendances : appels DB, services externes, temps courant, IDs générés
Décidez aussi jusqu’où vont les tests de handler. Les tests de handler sont les plus puissants à la frontière HTTP : auth, parsing, validation, codes de statut et corps d’erreur. Poussez les préoccupations plus profondes dans les tests d’intégration : requêtes réelles en base, appels réseau et routage complet.
Si votre backend est généré (par exemple, AppMaster produit des handlers Go et de la logique métier), une approche contract-first est encore plus utile. Vous pouvez régénérer le code et vérifier que chaque endpoint conserve le même comportement public.
Mettez en place un harnais minimal avec httptest
Un bon test de handler doit ressembler à l’envoi d’une vraie requête, sans démarrer de serveur. En Go, cela signifie généralement : construire une requête avec httptest.NewRequest, capturer la réponse avec httptest.NewRecorder et appeler votre handler.
Appeler le handler directement donne des tests rapides et ciblés. C’est idéal pour valider le comportement à l’intérieur du handler : vérifications d'auth, règles de validation, codes de statut et corps d’erreur. Utiliser un routeur dans les tests est utile quand le contrat dépend des paramètres de route, du matching ou de l’ordre des middleware. Commencez par des appels directs et ajoutez le routeur seulement si nécessaire.
Les en‑têtes comptent plus que la plupart des gens le pensent. Un Content-Type manquant peut changer la manière dont le handler lit le corps. Définissez les en‑têtes attendus dans chaque cas pour que les échecs pointent vers la logique et non la configuration du test.
Voici un modèle minimal réutilisable :
req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
Pour garder les assertions cohérentes, un petit helper pour lire et décoder le corps aide beaucoup. Dans la plupart des tests, vérifiez d’abord le code de statut (pour que les échecs soient faciles à scanner), puis les en‑têtes clés que vous promettez (souvent Content-Type), puis le corps.
Si votre backend est généré (y compris un backend Go produit par AppMaster), ce harnais s’applique toujours. Vous testez le contrat HTTP auquel les utilisateurs s’appuient, pas le style du code derrière.
Concevez des cas table-driven lisibles
Les tests table-driven fonctionnent mieux quand chaque cas se lit comme une petite histoire : la requête envoyée et ce que vous attendez en retour. Vous devriez pouvoir parcourir la table et comprendre la couverture sans sauter dans tout le fichier.
Un bon cas contient généralement : un nom clair, la requête (méthode, path, en‑têtes, corps), le code de statut attendu et une vérification de la réponse. Pour les corps JSON, préférez affirmer quelques champs stables (comme un code d'erreur) au lieu de matcher toute la chaîne JSON, sauf si votre contrat exige une sortie stricte.
Une forme simple de cas réutilisable
Gardez la structure de cas concentrée. Placez les configurations ponctuelles dans des helpers pour que la table reste compacte.
type tc struct {
name string
method string
path string
headers map[string]string
body string
wantStatus int
wantBody string // substring or compact JSON
}
Pour différents inputs, utilisez de petites chaînes de corps qui montrent la différence en un coup d'œil : un payload valide, un champ manquant, un mauvais type, une chaîne vide. Évitez de construire du JSON très formaté dans la table — ça devient vite bruyant.
Quand vous voyez une configuration répétée (création de token, en‑têtes communs, body par défaut), mettez‑la dans des helpers comme newRequest(tc) ou baseHeaders().
Si une table commence à mélanger trop d’idées, séparez‑la. Une table pour les chemins de succès et une autre pour les erreurs est souvent plus lisible et plus simple à déboguer.
Vérifications d'auth : les cas souvent sautés
Les tests d'auth semblent souvent corrects sur le chemin heureux, puis échouent en production parce qu’un petit cas n’a jamais été exercé. Traitez l’auth comme un contrat : ce que le client envoie, ce que le serveur renvoie et ce qu’il ne faut jamais révéler.
Commencez par la présence et la validité du token. Un endpoint protégé doit se comporter différemment quand l’en‑tête manque vs quand il est présent mais invalide. Si vous utilisez des tokens à courte durée de vie, testez aussi l’expiration, même si vous la simulez en injectant un validateur qui renvoie “expired”.
La plupart des lacunes sont couvertes par ces cas :
- Pas d'en‑tête
Authorization-> 401 avec une réponse d'erreur stable - En‑tête malformé (mauvais préfixe) -> 401
- Token invalide (signature incorrecte) -> 401
- Token expiré -> 401 (ou le code que vous choisissez) avec un message prévisible
- Token valide mais mauvais rôle/permissions -> 403
La distinction 401 vs 403 est importante. Utilisez 401 quand l’appelant n’est pas authentifié. Utilisez 403 quand il est authentifié mais pas autorisé. Si vous mélangez les deux, les clients relanceront inutilement ou afficheront une UI incorrecte.
Les vérifications de rôle ne suffisent pas pour les endpoints “propriétaires” (comme GET /orders/{id}). Testez la propriété : l’utilisateur A ne doit pas voir la commande de l’utilisateur B même avec un token valide. Cela devrait renvoyer proprement 403 (ou 404 si vous cachez volontairement l’existence), et le corps ne doit rien révéler. Gardez l’erreur générique. N’indiquez pas « la commande appartient à l’utilisateur 42 ».
Règles d'entrée : valider, rejeter et expliquer clairement
Beaucoup de bugs avant mise en production viennent des entrées : champs manquants, mauvais types, formats inattendus ou payloads trop volumineux.
Nommez chaque entrée acceptée par votre handler : champs du corps JSON, params de query et params de path. Pour chacun, décidez ce qui se passe quand il est manquant, vide, mal formé ou hors plage. Ensuite écrivez des cas qui prouvent que le handler rejette les mauvaises entrées tôt et renvoie le même type d’erreur à chaque fois.
Un petit ensemble de cas de validation couvre généralement la plupart des risques :
- Champs obligatoires : manquant vs chaîne vide vs null (si vous autorisez null)
- Types et formats : nombre vs chaîne, formats email/date/UUID, parsing boolean
- Limites de taille : longueur max, nombre max d'éléments, payload trop grand
- Champs inconnus : ignorés vs rejetés (si vous imposez un décodage strict)
- Query et path params : manquants, non analysables et comportements par défaut
Exemple : un handler POST /users accepte { "email": "...", "age": 0 }. Testez email manquant, email en 123, email en "not-an-email", age en -1 et age en "20". Si vous exigez du JSON strict, testez aussi { "email":"[email protected]", "extra":"x" } et confirmez que ça échoue.
Rendez les échecs de validation prévisibles. Choisissez un code de statut pour les erreurs de validation (certains utilisent 400, d'autres 422) et conservez une forme de corps d’erreur cohérente. Les tests doivent affirmer à la fois le statut et un message (ou un champ details) pointant l’entrée exacte en échec.
Codes de statut et corps d'erreur : rendez-les prévisibles
Les tests de handler deviennent plus simples quand les échecs d'API sont ennuyeux et cohérents. Vous voulez que chaque erreur corresponde à un code de statut clair et retourne la même forme JSON, quel que soit l’auteur du handler.
Commencez par une petite correspondance convenue entre types d'erreur et codes HTTP :
- 400 Bad Request : JSON malformé, params requis manquants
- 404 Not Found : l’ID de ressource n’existe pas
- 409 Conflict : contrainte d’unicité ou conflit d’état
- 422 Unprocessable Entity : JSON valide mais règles métier violées
- 500 Internal Server Error : échecs inattendus (DB down, nil pointer, outage tiers)
Ensuite, gardez le corps d’erreur stable. Même si le texte du message change plus tard, les clients doivent pouvoir compter sur des champs prévisibles :
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
Dans les tests, vérifiez la forme, pas seulement la ligne de statut. Une erreur courante est de renvoyer du HTML, du texte brut ou un corps vide sur les erreurs, ce qui casse les clients et masque les bugs.
Testez aussi les en‑têtes et l’encodage des réponses d'erreur :
Content-Typeestapplication/json(et le charset est cohérent si vous le définissez)- Le corps est du JSON valide même en cas d'erreur
code,messageetdetailsexistent (details peut être vide, mais pas aléatoire)- Les panics et erreurs inattendues retournent un 500 sécurisé sans fuite de traces de pile
Si vous ajoutez un middleware de recover, incluez un test qui force un panic et confirme que vous obtenez toujours une réponse JSON propre.
Cas limites : échecs, temps et chemins non heureux
Les tests du chemin heureux prouvent que le handler fonctionne une fois. Les tests des cas limites prouvent qu’il continue de se comporter quand le monde est imparfait.
Forcer les dépendances à échouer de manière spécifique et répétable. Si votre handler appelle une base de données, un cache ou une API externe, vous voulez voir ce qui se passe quand ces couches renvoient des erreurs hors de votre contrôle.
Ces cas valent la peine d’être simulés au moins une fois par endpoint :
- Timeout d’un appel en aval (
context deadline exceeded) - Not found depuis le stockage quand le client attend des données
- Violation de contrainte d’unicité sur création (email dupliqué, slug dupliqué)
- Erreur réseau ou de transport (connection refused, broken pipe)
- Erreur interne inattendue ("quelque chose s'est mal passé")
Gardez les tests stables en contrôlant tout ce qui peut varier entre exécutions. Un test instable est pire qu’aucun test, car il entraîne l’ignorance des échecs.
Rendez le temps et l'aléatoire prévisibles
Si le handler utilise time.Now(), des IDs ou des valeurs aléatoires, injectez‑les. Passez une fonction clock et un générateur d'ID au handler ou au service. En tests, renvoyez des valeurs fixes pour pouvoir affirmer des champs JSON et des en‑têtes exacts.
Utilisez de petits fakes et affirmez l'absence d'effets secondaires
Privilégiez de petits fakes ou stubs plutôt que des mocks lourds. Un fake peut enregistrer les appels et vous permettre d'affirmer qu'aucune action n'a eu lieu après un échec.
Par exemple, dans un handler "create user", si l'insertion en base échoue avec une erreur de contrainte d'unicité, affirmez le code de statut correct, le corps d'erreur stable et qu'aucun email de bienvenue n'a été envoyé. Votre fake mailer peut exposer un compteur (sent=0) pour prouver que le chemin d'échec n'a pas déclenché d'effets secondaires.
Erreurs courantes qui rendent les tests peu fiables
Les tests de handler échouent souvent pour de mauvaises raisons. La requête construite dans un test n'a pas la même forme qu'une vraie requête client. Cela conduit à des échecs bruyants et à une confiance trompeuse.
Un problème fréquent est d'envoyer du JSON sans les en‑têtes que le handler attend. Si votre code vérifie Content-Type: application/json, l'oublier peut faire sauter le décodage JSON, renvoyer un code différent ou prendre une branche qui n'arrive jamais en production. Même chose pour l'auth : un en‑tête Authorization manquant n'est pas équivalent à un token invalide. Ce sont des cas différents.
Un autre piège est d'assertionner toute la réponse JSON comme une chaîne brute. Les petits changements comme l'ordre des champs, l'indentation ou l'ajout de nouveaux champs cassent les tests alors que l'API reste correcte. Décodez le corps dans une struct ou un map[string]any, puis affirmez ce qui importe : statut, code d'erreur, message et quelques champs clés.
Les tests deviennent aussi peu fiables quand les cas partagent un état mutable. Réutiliser le même store en mémoire, des variables globales ou un router singleton entre lignes de table peut provoquer des fuites d'état entre cas. Chaque cas de test doit démarrer propre, ou réinitialiser l'état dans t.Cleanup.
Patterns qui rendent les tests fragiles :
- Construire des requêtes sans les mêmes en‑têtes et encodages que les clients réels
- Asserter des chaînes JSON complètes au lieu de décoder et vérifier des champs
- Réutiliser un état partagé (DB/cache/globals) entre cas
- Packager auth, validation et logique métier dans un seul test trop gros
Gardez chaque test ciblé. Si un cas échoue, vous devez savoir en quelques secondes s’il s’agit d’auth, de validation d’entrée ou du format d’erreur.
Une checklist rapide avant mise en production
Avant de livrer, les tests doivent prouver deux choses : l'endpoint respecte son contrat et il échoue de façon sûre et prévisible.
Exécutez ces vérifications en table-driven et faites en sorte que chaque cas affirme à la fois la réponse et les effets secondaires :
- Auth : pas de token, mauvais token, mauvais rôle, bon rôle (et confirmez que le cas "mauvais rôle" ne fuit pas d'informations)
- Entrées : champs obligatoires manquants, mauvais types, limites (min/max), champs inconnus à rejeter
- Sorties : code de statut, en‑têtes clés (comme
Content-Type), champs JSON requis, forme d'erreur cohérente - Dépendances : forcez une défaillance en aval (DB, queue, paiement, email), vérifiez un message sûr et l'absence d'écritures partielles
- Idempotence : répétez la même requête (ou retentez après un timeout) et confirmez qu'aucun doublon n'est créé
Après cela, ajoutez une assertion de sanity qui est souvent oubliée : confirmez que le handler n'a pas touché à ce qu'il ne devait pas. Par exemple, dans un cas de validation échouée, vérifiez qu'aucun enregistrement n'a été créé et qu'aucun email n'a été envoyé.
Si vous construisez des API avec un outil comme AppMaster, cette checklist reste applicable. Le principe est le même : verrouiller le comportement public que vos clients utilisent.
Exemple : un endpoint, une petite table et ce qu'elle attrape
Supposons un endpoint simple : POST /login. Il accepte du JSON avec email et password. Il renvoie 200 avec un token en cas de succès, 400 pour entrée invalide, 401 pour identifiants incorrects et 500 si le service d'auth est indisponible.
Une table compacte comme celle‑ci couvre la plupart des cas qui cassent en production.
func TestLoginHandler(t *testing.T) {
// Fake dependency so we can force 200/401/500 without hitting real systems.
auth := &FakeAuth{ /* configure per test */ }
h := NewLoginHandler(auth)
tests := []struct {
name string
body string
authHeader string
setup func()
wantStatus int
wantBody string
}{
{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
}
if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
}
})
}
}
Parcourez un cas de bout en bout : pour “missing password”, envoyez un body ne contenant que email, définissez Content-Type, passez via ServeHTTP, puis affirmez 400 et une erreur qui pointe clairement sur password. Ce seul cas prouve que votre décodeur, validateur et format d'erreur fonctionnent ensemble.
Si vous voulez une manière plus rapide de standardiser contrats, modules d'auth et intégrations tout en produisant du code Go réel, AppMaster (appmaster.io) est conçu pour ça. Même dans ce cas, ces tests restent précieux car ils verrouillent le comportement sur lequel vos clients comptent.


