Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func TestGetUserHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    rec := httptest.NewRecorder()

    GetUserHandler(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK)
    }
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func TestThirdPartyClient(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/api/v1/status" {
            http.NotFound(w, r)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write([]byte(`{"status":"ok"}`))
    }))
    t.Cleanup(server.Close)

    client := NewMyClient(server.URL)
    status, err := client.GetStatus()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if status != "ok" {
        t.Errorf("status: got %q, want %q", status, "ok")
    }
}

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 NewServer partout, 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 OK si l’utilisateur existe
  • 400 Bad Request si l’ID n’est pas un entier
  • 404 Not Found si l’utilisateur n’existe pas
  • 500 Internal Server Error si 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 :

1
2
3
if rec.Body.Len() == 0 {
    t.Fatal("expected non-empty body")
}

Ç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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var got UserResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
    t.Fatalf("invalid JSON body: %v\nbody: %s", err, rec.Body.String())
}
if got.ID != 1 {
    t.Errorf("ID: got %d, want 1", got.ID)
}
if got.Email != "alice@test.com" {
    t.Errorf("Email: got %q, want %q", got.Email, "alice@test.com")
}

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.

1
2
3
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
    t.Errorf("Content-Type: got %q, want %q", ct, "application/json")
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
func TestGetUserHandler(t *testing.T) {
    handler := setupTestRouter(t)

    cases := []struct {
        name        string
        path        string
        authHeader  string
        wantStatus  int
        wantInBody  string
    }{
        {
            name:       "valid_user",
            path:       "/users/1",
            authHeader: "Bearer valid-token",
            wantStatus: http.StatusOK,
            wantInBody: `"email":"alice@test.com"`,
        },
        {
            name:       "missing_auth",
            path:       "/users/1",
            authHeader: "",
            wantStatus: http.StatusUnauthorized,
            wantInBody: `"error":"missing authorization"`,
        },
        {
            name:       "invalid_id_format",
            path:       "/users/abc",
            authHeader: "Bearer valid-token",
            wantStatus: http.StatusBadRequest,
            wantInBody: `"error":"invalid user id"`,
        },
        {
            name:       "nonexistent_user",
            path:       "/users/9999",
            authHeader: "Bearer valid-token",
            wantStatus: http.StatusNotFound,
            wantInBody: `"error":"user not found"`,
        },
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            req := httptest.NewRequest(http.MethodGet, tc.path, nil)
            if tc.authHeader != "" {
                req.Header.Set("Authorization", tc.authHeader)
            }
            rec := httptest.NewRecorder()

            handler.ServeHTTP(rec, req)

            if rec.Code != tc.wantStatus {
                t.Errorf("status: got %d, want %d\nbody: %s", rec.Code, tc.wantStatus, rec.Body.String())
            }
            if !strings.Contains(rec.Body.String(), tc.wantInBody) {
                t.Errorf("body: got %s, want substring %q", rec.Body.String(), tc.wantInBody)
            }
        })
    }
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func setupTestRouter(t *testing.T) http.Handler {
    t.Helper()

    db := newFakeUserDB(map[int]User{
        1: {ID: 1, Email: "alice@test.com"},
        2: {ID: 2, Email: "bob@test.com"},
    })

    auth := func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Header.Get("Authorization") != "Bearer valid-token" {
                writeJSONError(w, http.StatusUnauthorized, "missing authorization")
                return
            }
            next.ServeHTTP(w, r)
        })
    }

    mux := http.NewServeMux()
    mux.Handle("GET /users/{id}", auth(NewGetUserHandler(db)))

    return mux
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
func TestAuthMiddleware(t *testing.T) {
    // Un handler bidon qui répond 200 avec l'utilisateur du contexte
    nextCalled := false
    next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        nextCalled = true
        user, _ := r.Context().Value(userContextKey).(*User)
        if user != nil {
            _, _ = w.Write([]byte(user.Email))
        }
    })

    middleware := AuthMiddleware(next)

    cases := []struct {
        name           string
        authHeader     string
        wantStatus     int
        wantNextCalled bool
        wantBody       string
    }{
        {"no_token", "", http.StatusUnauthorized, false, ""},
        {"invalid_token", "Bearer wrong", http.StatusUnauthorized, false, ""},
        {"valid_token", "Bearer valid-token", http.StatusOK, true, "alice@test.com"},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            nextCalled = false
            req := httptest.NewRequest(http.MethodGet, "/anything", nil)
            if tc.authHeader != "" {
                req.Header.Set("Authorization", tc.authHeader)
            }
            rec := httptest.NewRecorder()

            middleware.ServeHTTP(rec, req)

            if rec.Code != tc.wantStatus {
                t.Errorf("status: got %d, want %d", rec.Code, tc.wantStatus)
            }
            if nextCalled != tc.wantNextCalled {
                t.Errorf("next called: got %v, want %v", nextCalled, tc.wantNextCalled)
            }
            if rec.Body.String() != tc.wantBody {
                t.Errorf("body: got %q, want %q", rec.Body.String(), tc.wantBody)
            }
        })
    }
}

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

  1. 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.
  2. Cherche len(body) > 0 ou Body.Len() != 0 dans tes tests. Remplace par un json.Unmarshal dans une struct typée.
  3. Cherche Content-Type dans tes tests. Si tu n’en trouves pas, ajoute la ligne.
  4. 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.
  5. Si tu as plus de deux fonctions TestHandlerX_Cas, transforme-les en un seul table-driven test avec un helper setupTestRouter.

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é.


  1. Le package net/http/httptest est 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. ↩︎

  2. 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. ↩︎

  3. Note importante : t.Parallel() est sûr ici parce que chaque cas construit sa propre requête et son propre recorder. Le handler partagé 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 le t.Parallel() ou utilise une instance par cas. ↩︎

  4. Le pattern GET /users/{id} dans http.NewServeMux est arrivé avec Go 1.22. Avant, il fallait passer par un router tiers comme chi, gorilla/mux ou gin. 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 dans setupTestRouter↩︎

  5. Le linter tparallel détecte automatiquement les t.Parallel() mal placés, y compris dans les middlewares qui partagent des variables capturées. Si tu fais beaucoup de tests HTTP, ajoute-le à ton golangci-lint. Tu trouveras des bugs que tu ne soupçonnais pas. ↩︎