# httptest en Go : tester ses APIs HTTP sans jamais lancer de serveur

> Quatrième article de la série Testing Go. httptest permet de tester un handler HTTP sans lancer de serveur, sans réseau, sans flake. On voit NewRecorder pour l'unitaire, NewServer pour l'intégration, les cinq trous typiques de l'IA, et un pattern setupTestRouter réutilisable.


*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](/blog/ia-code-tu-testes/) 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](/blog/table-driven-tests-go/) qui ne mentent pas, et comment exploiter [t.Run, t.Parallel et t.Cleanup](/blog/subtests-trun-go/) 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 :

```go
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 avec[^1].

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 :

```go
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](/blog/subtests-trun-go/#tcleanup-vs-defer-le-piège-qui-attend-les-subtests-parallèles). 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](/blog/table-driven-tests-go/), 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 path[^2].

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 :

```go
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 :

```go
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.

```go
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 :

```go
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 mutable[^3]. 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 :

```go
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](/blog/subtests-trun-go/#thelper-des-stack-traces-qui-montrent-le-vrai-problème), 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 prod[^4].

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 :

```go
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éflexe[^5].

---

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

<div class="next-article">
<a href="/blog/interfaces-mocking-go/">
<span class="next-article__label">Article suivant de la série</span>
<p class="next-article__title">Interfaces et mocking en Go : tester sans dépendances externes</p>
<p class="next-article__desc">Le pattern « accept interfaces, return structs », les mocks manuels vs Testify/GoMock, et l'erreur classique de l'IA : tester l'implémentation au lieu du contrat.</p>
</a>
</div>

[^1]: Le package `net/http/httptest` est documenté sur [pkg.go.dev/net/http/httptest](https://pkg.go.dev/net/http/httptest). Il fait partie du standard library depuis les premières versions de Go (`NewRecorder` et `NewServer` remontent à Go 1.0, `NewRequest` a été ajouté en [Go 1.7 / août 2016](https://go.dev/doc/go1.7)), et son API est stable depuis — c'est l'un de ces rares packages qui ont été conçus correctement dès le départ.

[^2]: Le biais happy-path des LLM sur le code HTTP est largement documenté. PostHog le résume bien dans leur post [« What the AI Wizard taught us about LLM code generation at scale »](https://posthog.com/blog/correct-llm-code-generation) en observant que les modèles « écrivent le happy path magnifiquement et traitent la gestion d'erreur comme une réflexion après coup ». C'est un biais structurel : 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](https://go.dev/blog/routing-enhancements). Si ton projet utilise un router tiers, le principe reste identique — tu construis le router complet dans `setupTestRouter`.

[^5]: Le linter [`tparallel`](https://github.com/moricho/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.


