Cours 10 — JSON, serveur web, base de données et API
JSON en Go
Les APIs REST ont établi JSON comme moyen de communication standardisé entre services. Go fournit le package encoding/json pour convertir les types de données vers et depuis JSON (Marshaling : Go → JSON ; Unmarshaling : JSON → Go).
type Order struct {
ID string `json:"id"`
DateOrdered time.Time `json:"date_ordered"`
CustomerID string `json:"customer_id"`
}
Le tag json:"nom_champ" spécifie le nom du champ JSON associé — toujours recommandé, même s'il correspond au nom du champ Go. json:"-" ignore un champ (ex.: mot de passe), json:",omitempty" l'omet s'il est vide.
var o Order
err := json.Unmarshal([]byte(jsonData), &o) // pointeur requis
out, err := json.Marshal(o)
Pour des flux (fichiers, connexions réseau) sans tout charger en mémoire :
json.NewDecoder(r).Decode(&order) // io.Reader → struct
json.NewEncoder(w).Encode(order) // struct → io.Writer
Exemple complet — il décode la réponse JSON d'une vraie API (la météo d'openweathermap) dans des structures Go avec leurs tags.
Serveur web
Un serveur web accepte les requêtes HTTP. Ses ressources peuvent être pré-existantes (fichiers statiques) ou générées dynamiquement. À chaque requête, Go crée une goroutine (cours 4) pour traiter la requête — les handlers s'exécutent donc de manière concurrente.
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello! Il est présentement : %s", time.Now())
}
func main() {
http.HandleFunc("/hello", helloHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
*http.Request contient Method, URL, Header, Body (un io.ReadCloser).
On retrouve ici les interfaces du cours 9 : r.Body est un io.Reader (le corps de la requête, lu comme un flux) et w est un io.Writer. C'est pourquoi fmt.Fprintf(w, ...) fonctionne dans helloHandler : Fprintf écrit dans n'importe quel io.Writer, que ce soit os.Stdout, un fichier ou la réponse HTTP.
Une URL peut aussi porter des paramètres après un ?, comme /hello?nom=Michel. On les lit avec r.URL.Query().Get("nom"), qui retourne une chaîne vide si le paramètre est absent ; r.URL.Query().Has("nom") distingue un paramètre absent d'un paramètre vide.
Un formulaire HTML envoyé en POST se lit de façon semblable : r.ParseForm(), puis r.FormValue("nom") pour chaque champ. Exemple complet
Ressources partagées
Les handlers étant concurrents, toute variable globale partagée entre eux doit être protégée par un sync.Mutex (cours 6) :
func safeHandler(w http.ResponseWriter, r *http.Request) {
mu.Lock() // mu : un sync.Mutex global qui protège counter
counter++
current := counter
mu.Unlock()
fmt.Fprintf(w, "Requête #%d", current)
}
API REST
REST est un style d'architecture où une API manipule des ressources identifiées par des URLs, sans état (chaque requête est indépendante), avec les méthodes HTTP standards :
GET /articles → Liste tous les articles
GET /articles/5 → Récupère l'article #5
POST /articles → Crée un nouvel article
PUT /articles/5 → Met à jour l'article #5
DELETE /articles/5 → Supprime l'article #5
| Méthode | Action | Code succès typique |
|---|---|---|
| GET | Lire | 200 OK |
| POST | Créer | 201 Created |
| PUT | Remplacer | 200 OK |
| DELETE | Supprimer | 204 No Content |
Depuis Go 1.22, le routeur de la bibliothèque standard (http.ServeMux) suffit pour une API REST : un motif de route peut commencer par la méthode HTTP et contenir des paramètres entre accolades, qu'on récupère avec r.PathValue. Un paramètre est toujours une chaîne : strconv.Atoi la convertit en entier et retourne une erreur si ce n'est pas un nombre, ce qui permet de répondre 400 avec http.Error (détaillé plus bas) :
router := http.NewServeMux()
router.HandleFunc("GET /articles", getArticles)
router.HandleFunc("GET /articles/{id}", getArticle)
router.HandleFunc("POST /articles", createArticle)
// Démarrer le serveur avec ce routeur plutôt que celui par défaut (nil)
log.Fatal(http.ListenAndServe(":8080", router))
Voici le handler complet de GET /articles/{id} : il lit l'identifiant dans l'URL, cherche l'article et le retourne en JSON. Chaque sortie possible a son code HTTP : 400 si l'identifiant n'est pas un nombre, 404 si l'article n'existe pas, 200 sinon.
type Article struct {
ID int `json:"id"`
Title string `json:"title"`
}
var (
articles []Article // partagée entre tous les handlers
mu sync.Mutex // protège articles
)
func getArticle(w http.ResponseWriter, r *http.Request) {
// 1. Lire le paramètre : r.PathValue("id") retourne la chaîne "5" pour GET /articles/5
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "identifiant invalide", http.StatusBadRequest) // 400 pour /articles/abc
return
}
// 2. Chercher l'article, sous le verrou
var article Article
found := false
mu.Lock()
for _, a := range articles {
if a.ID == id {
article = a
found = true
break
}
}
mu.Unlock()
if !found {
http.Error(w, "article non trouvé", http.StatusNotFound) // 404
return
}
// 3. Retourner l'article en JSON (200 OK par défaut)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(article)
}
Une requête dont le chemin existe, mais pas pour cette méthode (par exemple PATCH /articles/5), reçoit automatiquement le code 405 Method Not Allowed ; un chemin qui ne correspond à aucune route reçoit 404.
Attention aux motifs qui se terminent par / : "GET /articles/" correspond à tout ce qui commence par /articles/, y compris /articles/5/commentaires. Pour n'accepter que le chemin exact, on termine le motif par {$} : "GET /articles/{$}".
Écrire la réponse : en-têtes, code HTTP, puis corps
Un handler qui crée une ressource lit le corps de la requête, valide les données, puis répond avec le bon code :
func createArticle(w http.ResponseWriter, r *http.Request) {
var article Article
if err := json.NewDecoder(r.Body).Decode(&article); err != nil {
http.Error(w, "JSON invalide", http.StatusBadRequest) // 400
return
}
if article.Title == "" {
http.Error(w, "Le titre est obligatoire", http.StatusBadRequest) // 400
return
}
// ... ajouter l'article à articles, sous le verrou
w.Header().Set("Content-Type", "application/json") // 1. les en-têtes
w.WriteHeader(http.StatusCreated) // 2. le code HTTP (201)
json.NewEncoder(w).Encode(article) // 3. le corps
}
http.Error écrit un code d'erreur et un message en une seule ligne ; il ne termine pas le handler, d'où le return qui suit. w.WriteHeader choisit le code de la réponse : si on ne l'appelle pas, la première écriture dans w envoie 200 OK.
Piège fréquent : l'ordre des appels. Le code HTTP et les en-têtes sont envoyés au client dès le premier w.WriteHeader ou la première écriture dans w. Tout ce qui vient après est trop tard : un w.Header().Set(...) placé après est ignoré, et un deuxième w.WriteHeader aussi (Go affiche alors http: superfluous response.WriteHeader call dans le journal du serveur). On écrit donc toujours dans l'ordre : en-têtes, code, corps.
Exemple complet — il regroupe ces trois étapes dans une fonction writeJSON, et montre toutes les réponses possibles de chaque route (400, 404, 409...) ; son README liste les requêtes à essayer.
Consommer une API externe
Le même package net/http sert aussi de client :
resp, err := http.Get("https://api.exemple.com/articles")
if err != nil {
return err
}
defer resp.Body.Close()
var articles []Article
json.NewDecoder(resp.Body).Decode(&articles)
http.Get n'a aucun délai : si le serveur ne répond jamais, l'appel reste bloqué. En pratique, on crée son propre client avec un délai, client := &http.Client{Timeout: 5 * time.Second}, puis on appelle client.Get(url). Il faut aussi vérifier resp.StatusCode : une réponse 404 ou 500 n'est pas une erreur pour http.Get, err vaut nil.
L'exemple 1, vu dans la section JSON, est un client complet : délai sur le client, traitement de chaque code de réponse (200, 401, 404, 429) et décodage du JSON reçu.
Piège fréquent : oublier resp.Body.Close(). Une réponse HTTP garde une connexion TCP sous-jacente ouverte tant que son corps n'est pas fermé (ou entièrement lu). Dans un service qui fait beaucoup d'appels sortants, cette fuite épuise progressivement les connexions disponibles (et les descripteurs de fichiers), jusqu'à ce que les requêtes suivantes commencent à échouer. Correction : defer resp.Body.Close() immédiatement après avoir vérifié err, comme dans l'extrait ci-dessus.
Quand l'utiliser
Dès qu'un service doit consommer une API tierce ou un autre microservice — c'est la norme dans une architecture réseau distribuée.
Bonnes pratiques d'une API REST
Toujours définir Content-Type: application/json, utiliser les bons codes HTTP, garder une structure d'URL cohérente (pluriel, minuscule, pas de verbe), valider les données entrantes.
Base de données
Go offre un package standard database/sql qui fournit une interface générique pour travailler avec différentes bases de données SQL (SQLite, PostgreSQL, MySQL, ...).
database/sql ne contient aucun pilote : on importe celui de la base utilisée. Le _ devant l'import indique qu'on n'utilise aucun nom du paquet dans notre code : on l'importe seulement pour que son code d'initialisation s'exécute et enregistre le pilote. Pour SQLite :
import (
"database/sql"
_ "github.com/mattn/go-sqlite3" // pilote SQLite, enregistré sous le nom "sqlite3"
)
db, err := sql.Open("sqlite3", "app.db")
if err != nil { /* nom de pilote inconnu (import oublié ?) */ }
if err := db.Ping(); err != nil { /* connexion échouée */ }
db.SetMaxOpenConns(25)
sql.Open n'ouvre pas une connexion : il prépare un pool de connexions, que *sql.DB gère tout seul et que tous les handlers peuvent utiliser en même temps. C'est db.Ping() qui vérifie que la base répond. db.SetMaxOpenConns(n) fixe le nombre maximal de connexions ouvertes en même temps ; par défaut, il n'y a pas de limite. Une requête qui arrive quand toutes les connexions sont occupées attend qu'une se libère.
CRUD de base — toujours avec des requêtes paramétrées (souvent appelées requêtes préparées), où un ? marque la place de chaque valeur, jamais de concaténation de chaînes :
// Lecture d'une seule ligne
var nom string
err := db.QueryRow("SELECT nom FROM users WHERE id = ?", id).Scan(&nom)
// Écriture
result, err := db.Exec("INSERT INTO users(nom) VALUES (?)", nom)
id, err := result.LastInsertId() // identifiant de la ligne créée
Quand aucune ligne ne correspond, Scan retourne l'erreur sql.ErrNoRows, qu'on reconnaît avec err == sql.ErrNoRows pour répondre 404 plutôt que 500.
Pour lire plusieurs lignes, db.Query retourne un curseur (*sql.Rows), c'est-à-dire un résultat qu'on lit une ligne à la fois. L'extrait suivant est dans une fonction qui retourne une error ; dans un handler, on remplace return err par http.Error(...) suivi de return :
rows, err := db.Query("SELECT id, nom FROM users WHERE actif = ?", true)
if err != nil {
return err
}
defer rows.Close() // libère la connexion, même si on sort avant la fin
for rows.Next() { // avance d'une ligne ; false quand il n'y en a plus
var id int
var nom string
if err := rows.Scan(&id, &nom); err != nil {
return err
}
// ... utiliser id et nom
}
if err := rows.Err(); err != nil { // erreur survenue pendant le parcours
return err
}
Piège fréquent : oublier rows.Close(). Tant que le curseur n'est pas fermé, il garde une connexion du pool. Un curseur parcouru jusqu'au bout se ferme tout seul ; mais si on sort de la boucle avant la fin (un return sur une erreur, par exemple) sans rows.Close(), la connexion n'est jamais rendue. Avec une limite SetMaxOpenConns, le pool finit par se vider et les requêtes suivantes restent bloquées ; sans limite, les connexions ouvertes s'accumulent. Même principe que resp.Body.Close() plus haut : defer rows.Close() tout de suite après avoir vérifié err.
Sécurité : injection SQL
Concaténer directement l'entrée utilisateur dans le SQL est dangereux :
// ❌ CODE DANGEREUX
query := fmt.Sprintf("SELECT uid FROM Users WHERE name='%s' AND password='%s'", username, password)
Avec Mot de passe: ' OR '1'='1, la condition devient toujours vraie. La protection : avec une requête paramétrée (?), le texte SQL et les valeurs sont transmis séparément à la base de données. Une valeur est donc toujours traitée comme une donnée, jamais comme du code SQL, peu importe ce qu'elle contient.
Transactions
Quand une opération demande plusieurs requêtes SQL qui doivent réussir ensemble ou pas du tout (par exemple retirer une carte d'un paquet et l'ajouter à une main), on les regroupe dans une transaction. Si une étape échoue, Rollback annule tout ce qui a été fait depuis le début de la transaction ; si tout réussit, Commit rend les changements définitifs :
func transferer(db *sql.DB, carte string, main string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // sans effet si Commit a déjà réussi
if _, err := tx.Exec("DELETE FROM paquet WHERE carte = ?", carte); err != nil {
return err // le defer annule la transaction
}
if _, err := tx.Exec("INSERT INTO mains(main, carte) VALUES (?, ?)", main, carte); err != nil {
return err
}
return tx.Commit()
}
Le defer tx.Rollback() garantit que la transaction est annulée dès qu'on sort de la fonction par une erreur. Après un Commit réussi, le Rollback ne fait plus rien. Il existe aussi une version avec contexte, db.BeginTx(ctx, nil), présentée plus bas avec le serveur web.
Exemple complet — il utilise les versions avec contexte présentées plus bas ; son README liste les requêtes à essayer.
Bonnes pratiques avec une base de données
Toujours des requêtes paramétrées, defer rows.Close(), transactions pour les opérations liées, valider les entrées, ne jamais journaliser de mots de passe.
Le contexte dans le serveur web et la base de données
Au cours 9, on a vu le package context avec des goroutines et des flux io ordinaires. Maintenant qu'on connaît les handlers et database/sql, on peut l'appliquer là où il sert le plus : le traitement d'une requête HTTP.
Le contexte de chaque requête
Bonne nouvelle : net/http et database/sql intègrent déjà context. Chaque requête HTTP entrante a son propre contexte (r.Context()), annulé automatiquement quand le client se déconnecte. Il suffit de le propager aux appels BD, avec les versions ...Context des méthodes vues plus haut (QueryRowContext pour QueryRow, QueryContext pour Query, ExecContext pour Exec, BeginTx pour Begin). Dans une transaction, les mêmes versions existent sur tx (tx.ExecContext, tx.QueryContext), et le nil de db.BeginTx(ctx, nil) demande les options par défaut :
// ✅ Avec contexte — la requête est annulée si ctx expire (client parti)
db.QueryRowContext(r.Context(), "SELECT nom FROM utilisateurs WHERE id = ?", id).Scan(&nom)
// ❌ Sans contexte — la requête continue même si le client est parti
db.QueryRow("SELECT nom FROM utilisateurs WHERE id = ?", id).Scan(&nom)
Un client qui se déconnecte n'annule que son propre travail. C'est l'arbre de contextes vu au cours 9 : net/http dérive, avec WithCancel, un contexte pour chaque connexion, puis un autre pour chaque requête. Chaque connexion, et donc chaque client, a ainsi sa propre branche de l'arbre. Quand un client se déconnecte, seuls sa requête et le travail qui utilise son contexte (requêtes BD, goroutines) sont annulés ; les requêtes des autres clients, dans d'autres branches, continuent normalement. Inutile donc de créer soi-même un contexte par requête : r.Context() en fournit déjà un.
Passer des valeurs avec context.WithValue
En plus de l'annulation et du délai vus au cours 9, un contexte peut porter des valeurs. On peut les attacher au contexte pour les transmettre implicitement à toutes les fonctions qui le reçoivent, sans ajouter un paramètre à chacune. C'est utile pour des données transversales, qui concernent tout le traitement plutôt qu'une fonction en particulier : un identifiant de traitement pour relier entre eux les messages affichés, l'utilisateur pour le compte duquel on travaille, etc.
type cleCtx string
const cleIDTraitement cleCtx = "idTraitement"
ctx := context.WithValue(context.Background(), cleIDTraitement, "tr-123") // attacher
id, ok := ctx.Value(cleIDTraitement).(string) // lire : "tr-123", true
ctx.Value(clé) cherche la clé dans le contexte, puis dans ses parents, et retourne nil si elle est absente. La valeur retournée est de type any (n'importe quel type) : pour la récupérer comme une string, on utilise une assertion de type, .(string). Sous la forme à deux valeurs (id, ok := ...), ok vaut false si la clé est absente ou si la valeur n'est pas une string, au lieu de provoquer une panique.
Contrairement à WithCancel ou WithTimeout, WithValue ne retourne pas de fonction cancel : il n'y a rien à annuler.
La clé est d'un type défini pour l'occasion (cleCtx) plutôt qu'une simple string : la documentation de context le recommande, pour éviter qu'un autre paquet qui utiliserait lui aussi la clé "idTraitement" n'écrase notre valeur.
⚠️ À utiliser avec modération. Les valeurs ne sont pas vérifiées à la compilation. Préférer les arguments de fonction explicites pour les données métier ; réserver
WithValueaux données transversales (ID de requête, authentification, traces).
Middleware : ajouter un délai ou une valeur à chaque requête
Un middleware est une fonction qui enveloppe un handler : elle reçoit le handler suivant (next), fait un traitement avant de l'appeler, puis lui passe la requête. C'est l'endroit naturel pour ajouter au contexte ce qui concerne toutes les requêtes, comme un délai maximal ou l'utilisateur authentifié. Comme on ne peut pas modifier le contexte d'une requête existante, on en crée une copie qui porte le nouveau contexte, avec r.WithContext(ctx) :
// http.HandlerFunc est le type d'une fonction handler : func(w http.ResponseWriter, r *http.Request)
func middlewareAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// cleUserID est une clé de type cleCtx, comme cleIDTraitement plus haut.
// En vrai, l'identifiant viendrait du jeton d'authentification
ctx := context.WithValue(r.Context(), cleUserID, uint(42))
next(w, r.WithContext(ctx))
}
}
Un middleware qui impose un délai s'écrit de la même façon. Les lignes vont dans la fonction retournée, exécutée à chaque requête ; defer cancel() s'exécute quand next a terminé :
func middlewareTimeout(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
next(w, r.WithContext(ctx))
}
}
On branche un middleware en enveloppant le handler au moment de l'enregistrer, et plusieurs middlewares s'empilent :
// Le délai enveloppe l'authentification, qui enveloppe le handler
router.HandleFunc("GET /profil", middlewareTimeout(middlewareAuth(profilHandler)))
Le handler (profilHandler) lit la valeur et propage le contexte jusqu'à la base de données. Quand la requête SQL échoue, la cause détermine le code de la réponse :
ctx := r.Context()
userID, _ := ctx.Value(cleUserID).(uint)
var nom string
err := db.QueryRowContext(ctx, "SELECT nom FROM utilisateurs WHERE id = ?", userID).Scan(&nom)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
http.Error(w, "Délai dépassé", http.StatusServiceUnavailable) // 503
} else if err == sql.ErrNoRows {
http.Error(w, "Utilisateur non trouvé", http.StatusNotFound) // 404
} else {
http.Error(w, "Erreur serveur", http.StatusInternalServerError) // 500
}
return
}
// ... répondre 200 avec nom
Exemple complet — pour s'exécuter sans base de données, il remplace la requête SQL par une fonction qui simule une base lente (?lent=1), et son middlewareAuth lit l'utilisateur dans l'en-tête X-User-ID (401 s'il est absent) au lieu de l'injecter en dur. Les requêtes à essayer sont dans son README.
Détacher un travail de la requête avec context.WithoutCancel
Jusqu'ici, l'annulation en cascade est exactement ce qu'on veut : si le client part, tout le travail fait pour lui s'arrête. Mais certains travaux déclenchés par une requête doivent se terminer même si la requête, elle, est terminée. Par exemple, écrire en base de données une entrée dans un journal d'audit (« l'utilisateur 42 a supprimé son compte ») ou envoyer un courriel de confirmation. Le client n'a pas besoin d'attendre ces travaux pour recevoir sa réponse, mais on ne veut pas les perdre.
Le réflexe est de lancer ce travail dans une goroutine avec le contexte de la requête. C'est un piège : selon la documentation de net/http, le contexte d'une requête entrante est annulé quand le client se déconnecte, mais aussi dès que le handler retourne. La goroutine est donc annulée au moment précis où le handler envoie sa réponse :
// BUG : r.Context() est annulé dès que le handler retourne
go journaliserAudit(r.Context(), "compte supprimé")
w.WriteHeader(http.StatusNoContent) // 204 : succès, réponse sans contenu
Utiliser context.Background() à la place éviterait l'annulation, mais on perdrait au passage les valeurs de la requête (l'utilisateur connecté injecté par le middleware, l'ID de requête, etc.), dont le journal d'audit a justement besoin.
context.WithoutCancel(parent) (disponible depuis Go 1.21) résout les deux problèmes : il crée un contexte dérivé qui garde les valeurs du parent, mais qui n'est jamais annulé quand le parent l'est. Il n'a pas non plus de délai : ctx.Err() retourne toujours nil et ctx.Done() ne se ferme jamais. Comme il n'y a rien à annuler, il retourne seulement le contexte, sans fonction cancel.
// Garde les valeurs de la requête (userID, etc.), mais pas son annulation
ctxDetache := context.WithoutCancel(r.Context())
go func() {
// Le contexte détaché n'a aucun délai : on lui en redonne un
ctx, cancel := context.WithTimeout(ctxDetache, 10*time.Second)
defer cancel()
journaliserAudit(ctx, "compte supprimé") // peut encore lire ctx.Value(cleUserID)
}()
w.WriteHeader(http.StatusNoContent)
Le WithTimeout ajouté dans la goroutine n'est pas optionnel. Sans lui, si la base de données qui conserve le journal ne répond plus, la goroutine peut attendre indéfiniment : c'est une fuite de goroutine, puisque plus personne ne peut l'annuler. Ce délai ne fonctionne évidemment que si journaliserAudit transmet ctx à ses propres appels (par exemple ses requêtes SQL avec contexte).
Exemple complet — il compare la version boguée (/compte-bug) et la version corrigée (/compte) ; les requêtes à essayer sont dans son README.
Quand l'utiliser
Pour un travail court déclenché par une requête qui doit aller jusqu'au bout même après la réponse ou la déconnexion du client, et qui a besoin des valeurs de la requête : journal d'audit, courriel de confirmation, mise à jour d'un cache, statistiques.
Quand l'éviter
Pour tout travail dont le résultat ne sert qu'au client : si le client est parti, continuer ne fait que gaspiller des ressources, et c'est précisément ce que l'annulation évite. Et jamais sans redonner un délai avec WithTimeout ou WithDeadline, pour la raison expliquée ci-dessus.
Inutile aussi pour simplement fermer ou libérer une ressource (par exemple conn.Close()) : ces fonctions ne prennent pas de contexte, et un defer s'exécute toujours quand la fonction retourne, que le contexte soit annulé ou non. WithoutCancel ne sert que pour une nouvelle opération qui prend elle-même un contexte (une écriture en base avec db.ExecContext, la version avec contexte de db.Exec ; ou un appel HTTP) : avec un contexte déjà annulé, elle échouerait immédiatement avec context canceled.
Tester les handlers avec net/http/httptest
Lancer le serveur puis l'essayer à la main avec curl ne suffit pas : on veut des tests automatiques, exécutés avec go test comme au cours 4. Le package net/http/httptest de la bibliothèque standard fournit deux outils pour ça.
Tester un handler seul : NewRecorder
Un handler est une simple fonction qui reçoit un http.ResponseWriter et une *http.Request. Pour le tester, pas besoin de serveur ni de réseau : on lui passe une requête fabriquée avec httptest.NewRequest, et un httptest.ResponseRecorder qui enregistre tout ce que le handler écrit dans la réponse.
func TestBonjourHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bonjour", nil)
rec := httptest.NewRecorder()
bonjourHandler(rec, req) // le handler testé répond "Hello!"
if rec.Code != http.StatusOK {
t.Fatalf("code HTTP : obtenu %d, attendu %d", rec.Code, http.StatusOK)
}
if rec.Body.String() != "Hello!" {
t.Errorf("corps : obtenu %q, attendu %q", rec.Body.String(), "Hello!")
}
}
rec.Code contient le code HTTP écrit par le handler (200 si le handler n'en a pas choisi d'autre), et rec.Body le corps de la réponse : c'est un *bytes.Buffer, donc un io.Writer dans lequel le handler écrit, comme vu au cours 9. t.Fatalf fonctionne comme t.Errorf, mais arrête le test immédiatement : inutile de vérifier le corps si le code HTTP est déjà faux.
Pour une réponse JSON, on décode rec.Body avec json.NewDecoder(rec.Body).Decode(&resultat), puis on compare les champs. Pour une requête avec un corps, comme un POST, le troisième argument de NewRequest est un io.Reader : par exemple strings.NewReader(`{"title":"Dune"}`).
Quand le handler lit un paramètre de route avec r.PathValue, c'est le routeur qui remplit cette valeur. On passe alors la requête au routeur plutôt qu'au handler : router.ServeHTTP(rec, req). Le routeur est lui-même un handler : sa méthode ServeHTTP(w, r) traite une requête en choisissant le bon handler d'après la méthode et le chemin, exactement comme pour une vraie requête. C'est aussi pourquoi on peut le passer directement à httptest.NewServer(router).
Tester sous charge : NewServer
httptest.NewServer démarre un vrai serveur HTTP sur un port libre de la machine, le temps du test. On peut alors lui envoyer de vraies requêtes, et même beaucoup en même temps, pour vérifier qu'un handler résiste à la concurrence :
// Dans une fonction de test ; http.HandlerFunc(safeHandler) convertit la fonction en handler
srv := httptest.NewServer(http.HandlerFunc(safeHandler))
defer srv.Close() // arrête le serveur à la fin du test
// ... lancer 100 goroutines qui font chacune http.Get(srv.URL) et ferment resp.Body,
// puis les attendre avec un sync.WaitGroup
if counter != 100 {
t.Errorf("compteur : obtenu %d, attendu 100", counter)
}
srv.URL est l'adresse du serveur de test. Ici, safeHandler est le handler de la section « Serveur web », qui incrémente le compteur partagé counter protégé par un sync.Mutex. Lancé avec go test -race, ce test passe. Si on retire le mutex, le détecteur signale DATA RACE et le test échoue : c'est exactement ce genre de test qu'on veut garder pour détecter une régression.
Quand l'utiliser
NewRecorder pour tester la logique d'un handler (codes HTTP, messages d'erreur, contenu JSON) : c'est rapide et ne dépend d'aucun réseau. NewServer quand on a besoin d'un vrai échange HTTP : requêtes simultanées, middlewares complets, client qui se déconnecte.
Quand l'éviter
Pour tester une fonction qui ne dépend pas de HTTP (calcul, validation, accès au worker pool), un test unitaire classique qui appelle directement la fonction est plus simple et plus précis : si le test échoue, on sait tout de suite que le problème n'est pas dans la couche HTTP.