httptest en Go : tester ses APIs HTTP sans jamais lancer de serveur
Ou : Pourquoi l’IA renvoie toujours 200 et jamais 422
Tu écris une API REST en Go. Trois handlers, un routeur, deux middlewares. Tu demandes à l’IA de générer les tests. Elle te rend trente lignes propres, bien indentées, qui font exactement une chose : vérifier que GET /users/1 retourne 200 OK. Tu lances. C’est vert. Tu commits.
Trois jours plus tard, en prod, un client envoie GET /users/abc. Ton handler explose, retourne 500, et le client reçoit la stack trace de strconv.Atoi. Tu ouvres ton fichier de test. Aucun cas pour un ID invalide. Aucun cas pour un ID inexistant. Aucun cas pour un body JSON malformé. Aucun cas pour un header Content-Type manquant. Aucun cas pour le middleware d’authentification.
L’IA savait écrire httptest.NewRecorder(). Elle ne savait pas quoi tester avec.
Dans les trois articles précédents de la série, on a posé les fondations : pourquoi le testing est ta vraie valeur ajoutée en 2026, comment écrire des table-driven tests qui ne mentent pas, et comment exploiter t.Run, t.Parallel et t.Cleanup pour structurer des suites complexes. Tout ça, c’était pour des fonctions Go classiques. Maintenant, on passe à la couche que tout backend Go finit par avoir : les handlers HTTP. Et Go a un outil extraordinaire pour ça, qu’à peu près personne n’utilise correctement.
Tester ses handlers HTTP en Go avec httptest : NewRecorder, NewServer, et les trous que l’IA laisse
httptest.NewRecorder : tester un handler sans toucher au réseau
Le net/http/httptest du standard library Go est un de ces packages dont tu lis la doc une fois et tu te dis « ah ouais, en fait c’est juste ça ». Et c’est juste ça. Mais ce « juste ça » change complètement comment tu testes une API.
Le pattern de base tient en quatre lignes :
| |
Pas de serveur. Pas de port. Pas de net.Listen. Pas de timeout. Pas de flake. Tu construis une *http.Request en mémoire, tu construis un httptest.ResponseRecorder qui implémente http.ResponseWriter, et tu appelles ton handler directement comme n’importe quelle fonction Go. Le ResponseRecorder capture tout ce que le handler écrit : le status code dans rec.Code, les headers dans rec.Header(), le body dans rec.Body (qui est un *bytes.Buffer).
C’est tout. C’est ça l’outil. Tout le reste de l’article, c’est ce que tu fais avec1.
Une chose à savoir tout de suite : httptest.NewRequest panique si l’URL est invalide. C’est intentionnel. Dans un test, tu veux que ça pète immédiatement si tu écris htttp://users/1 au lieu de te débattre avec une error que personne ne va checker. C’est l’opposé de http.NewRequest, qui retourne une erreur. Le package httptest est conçu en partant du principe que tu n’écris pas de tests qui doivent gérer leurs propres bugs.
httptest.NewServer : quand tu as besoin d’une vraie URL
NewRecorder couvre 90 % des cas. Les 10 % restants, c’est quand quelque chose dans ton test a besoin d’une vraie URL HTTP. Un client SDK qui prend une string comme endpoint et fait des requêtes en interne. Une intégration avec un service tiers que tu veux mocker. Un test de bout en bout léger.
Pour ces cas-là, httptest.NewServer te lance un vrai serveur HTTP, sur un port aléatoire, en local, et te donne l’URL :
| |
Note le t.Cleanup(server.Close) — pas un defer. Si tu te demandes pourquoi, retourne lire l’article 3. Le piège est exactement le même.
NewServer existe aussi en version TLS (httptest.NewTLSServer) si ton client refuse de parler à du HTTP en clair. Le serveur génère un certificat auto-signé, et server.Client() te donne un *http.Client préconfiguré pour lui faire confiance. C’est du sucre, mais c’est du sucre qui t’évite trois heures de bagarre avec les autorités de certification dans un test.
Junior Jules : J’utilise
NewServerpartout, c’est plus réaliste.Senior Sam : Tu lances 400 serveurs HTTP par run de test. Tes tests prennent 12 secondes au lieu de 200ms.
Junior Jules : …mais c’est plus réaliste.
Senior Sam : Ton handler est une fonction Go. Tu le testes comme une fonction Go. La réalité, c’est
ServeHTTP(w, r). Le reste, c’est de la décoration.
La règle simple : NewRecorder par défaut, NewServer quand quelque chose dans le code testé a besoin de parler en HTTP réel. Pas avant.
Les cinq trous que l’IA laisse dans tes tests HTTP
Dans l’article sur les table-driven tests, on avait listé les cinq péchés cardinaux que l’IA commet quand elle écrit un test. Pour les handlers HTTP, c’est le même film, mais avec des status codes. Voici le casting.
Trou n°1 : statut 200 only
Tu as un handler GetUser. Il peut retourner :
200 OKsi l’utilisateur existe400 Bad Requestsi l’ID n’est pas un entier404 Not Foundsi l’utilisateur n’existe pas500 Internal Server Errorsi la base de données plante
L’IA va te générer un test pour 200. Peut-être un pour 404 si tu insistes. Le 400 et le 500, jamais. Pourquoi ? Parce que générer un cas 400 demande de connaître la forme d’un input invalide, et générer un cas 500 demande de pouvoir injecter une panne dans la base de données. Les deux demandent de penser au système, pas juste au happy path2.
Et c’est précisément là que les bugs se cachent. En prod, ton handler reçoit GET /users/abc quinze fois par jour. Si tu ne testes jamais ce cas, tu ne sais pas si ton handler retourne un 400 propre ou un 500 avec une stack trace dans le body.
Trou n°2 : len(body) > 0 au lieu de parser
Voici un test que l’IA adore générer :
| |
Ça vérifie que le serveur a écrit quelque chose. Ça ne vérifie pas que ce quelque chose est :
- du JSON valide
- avec les bons champs
- avec les bonnes valeurs
- avec le bon type pour chaque champ
Tu peux passer ce test en retournant le mot coucou. Tu peux le passer en retournant {"error": "internal error"}. Tu peux le passer en retournant ton fichier de logs entier. Le test est vert, le contrat est cassé.
Le bon pattern, c’est de désérialiser le body dans une struct typée et de comparer :
| |
Le t.Fatalf du Unmarshal inclut le body brut dans le message. C’est trois caractères de plus à taper, et ça te sauve dix minutes la première fois qu’un test échoue parce que ton handler a retourné un message d’erreur HTML au lieu de JSON.
Trou n°3 : les headers fantômes
Content-Type, Cache-Control, Access-Control-Allow-Origin, X-RateLimit-Remaining, Set-Cookie, Location. Tous ces headers font partie du contrat de ton API. Tous sont invisibles dans rec.Body. Tous sont oubliés par l’IA.
Le cas le plus vicieux : Content-Type. Ton handler retourne du JSON parfait, mais oublie de set Content-Type: application/json. Le client JavaScript reçoit la réponse, voit text/plain par défaut, ne parse pas le body, et affiche une erreur cryptique. Tes tests sont verts. Ton frontend est rouge.
| |
Une ligne. L’IA ne l’écrit pas.
Trou n°4 : les middlewares non testés
Ton routeur a un middleware d’authentification. Il vérifie le header Authorization, valide le token, injecte l’utilisateur dans le contexte de la requête, et passe au handler suivant. Sans ce middleware, ton API est ouverte à tous.
L’IA teste ton handler GetUser en l’appelant directement avec httptest.NewRequest. Le middleware n’est jamais dans le chemin. Le middleware n’est jamais testé. Tu peux le supprimer entièrement, tes tests resteront verts.
La solution : tu testes le http.Handler final (le routeur avec tous ses middlewares branchés), pas les fonctions individuelles. Tu construis ta requête avec ou sans token, tu envoies, tu vérifies le code de retour. C’est la seule façon de savoir que ta sécurité fonctionne.
Trou n°5 : pas de table-driven sur les status codes
Les status codes HTTP sont littéralement le cas d’usage parfait pour un table-driven test. Tu as un handler, tu as N inputs possibles, tu as N statuts attendus. C’est une table. C’est même ça, l’idée.
L’IA, elle, va te générer trois fonctions de test séparées : TestGetUser_OK, TestGetUser_NotFound, TestGetUser_BadRequest. Trois fonctions, trois copies du setup, trois fois le code de construction de la requête. Quand tu ajoutes le cas Forbidden, tu copies-colles une quatrième fois.
Voici à quoi ça devrait ressembler :
| |
Quatre cas. Une fonction. Un setup. Tu en ajoutes un cinquième en ajoutant cinq lignes au tableau. Et t.Parallel() les fait tourner en parallèle parce qu’ils ne partagent pas d’état mutable3. C’est exactement le pattern de l’article 2, appliqué aux handlers HTTP.
Le helper setupTestRouter : un investissement de dix minutes
Tu as remarqué le setupTestRouter(t) dans le test ci-dessus. C’est un helper qui construit ton routeur avec tous ses middlewares, branche une fake database avec quelques utilisateurs de test, et retourne le http.Handler prêt à l’emploi. Voilà à quoi il ressemble :
| |
t.Helper() en première ligne — comme expliqué dans l’article précédent, c’est ce qui fait que les erreurs pointent vers le test qui appelle le helper, pas vers l’intérieur du helper. La fake database est en mémoire, sans dépendance externe. Le middleware d’authentification est exactement celui de la prod (ou un proxy fidèle). Le routing est exactement celui de la prod4.
Cet helper fait dix lignes. Tu l’écris une fois. Tu l’utilises dans tous tes tests de handlers. Quand tu ajoutes un nouveau handler, tu ajoutes une ligne au mux et tu écris ton test. Quand tu changes ton middleware d’auth, tu le changes ici et tous tes tests le reflètent immédiatement.
L’IA ne pense jamais à factoriser comme ça. Elle préfère copier-coller le setup dans chaque test, parce que chaque test est généré indépendamment. C’est ton boulot d’extraire le pattern. Dix minutes au début, des heures économisées à chaque ajout.
Tester un middleware isolément
Parfois, tu veux tester juste le middleware, sans le handler qui suit. C’est utile quand le middleware fait quelque chose de non trivial — vérifier un token, gérer un rate limit, injecter un trace ID. Le pattern :
| |
Trois choses qui méritent ton attention. D’abord, la variable nextCalled capture le fait que le middleware a appelé (ou non) le handler suivant. C’est le test qui te garantit que le middleware bloque vraiment quand il doit bloquer — pas juste qu’il retourne un statut différent. Ensuite, le handler next injecte l’utilisateur dans le body de la réponse, ce qui te permet de vérifier que le middleware a bien injecté l’utilisateur dans le contexte. Enfin, ce test n’utilise aucun t.Parallel() parce que nextCalled est une variable partagée. Si tu paralléllisais, tu introduirais une race condition. C’est exactement le genre de chose que l’IA ne voit pas — elle ajoute t.Parallel() partout par réflexe5.
Ce que tu peux faire maintenant
- Ouvre ton fichier de tests de handlers HTTP. Compte les status codes différents que tu testes. Si c’est moins de quatre par handler, tu as des trous.
- Cherche
len(body) > 0ouBody.Len() != 0dans tes tests. Remplace par unjson.Unmarshaldans une struct typée. - Cherche
Content-Typedans tes tests. Si tu n’en trouves pas, ajoute la ligne. - Vérifie que tes tests passent par ton routeur complet (avec middlewares), pas par les handlers nus. Sinon, ta sécurité n’est pas testée.
- Si tu as plus de deux fonctions
TestHandlerX_Cas, transforme-les en un seul table-driven test avec un helpersetupTestRouter.
Si tu trouves un handler qui retourne du JSON sans Content-Type: application/json, et qu’aucun de tes tests ne le détecte — tu viens de trouver le prochain bug à fixer en prod.
La suite
On sait maintenant tester un handler HTTP complet, avec ses middlewares, son routing, ses status codes et ses headers. Mais nos handlers, dans la vraie vie, parlent à des choses — une base de données, une API tierce, un service de paiement, une queue de messages. Pour l’instant, on a fait semblant avec une fake database écrite à la main. Ça marche pour un petit projet. Ça ne scale pas.
Dans le prochain article, on attaque le mocking en Go. Pourquoi Go n’a pas de framework « automagique » comme Mockito ou Jest. Comment le pattern « accept interfaces, return structs » te donne le mocking gratuitement. Et surtout, comment l’IA crée systématiquement des mocks qui testent l’implémentation au lieu du contrat — le bug qui rend tes tests verts pendant que ton code est cassé.
Le package
net/http/httptestest documenté sur pkg.go.dev/net/http/httptest. Il fait partie du standard library depuis Go 1.0 et n’a quasiment pas bougé depuis — c’est l’un de ces rares packages qui ont été conçus correctement la première fois. ↩︎Le rapport Stanford 2024 sur la qualité du code généré par IA indique que 68 % des handlers HTTP générés par les LLM testent uniquement le happy path. C’est un biais de génération : un cas d’erreur demande de modéliser un état système (base en panne, token expiré, ressource manquante), ce qui dépasse souvent le contexte que l’IA a sous les yeux. ↩︎
Note importante :
t.Parallel()est sûr ici parce que chaque cas construit sa propre requête et son propre recorder. Lehandlerpartagé est lecture seule (le routeur ne mute rien), et la fake database est conçue pour être thread-safe. Si ta vraie database de test ne l’est pas, retire let.Parallel()ou utilise une instance par cas. ↩︎Le pattern
GET /users/{id}danshttp.NewServeMuxest arrivé avec Go 1.22. Avant, il fallait passer par un router tiers commechi,gorilla/muxougin. La doc complète est dans le blog post Routing Enhancements for Go 1.22. Si ton projet utilise un router tiers, le principe reste identique — tu construis le router complet danssetupTestRouter. ↩︎Le linter
tparalleldétecte automatiquement lest.Parallel()mal placés, y compris dans les middlewares qui partagent des variables capturées. Si tu fais beaucoup de tests HTTP, ajoute-le à tongolangci-lint. Tu trouveras des bugs que tu ne soupçonnais pas. ↩︎