09 ago 2025·7 min di lettura

Testare gli handler REST in Go: httptest e test table-driven

Testare gli handler REST in Go con httptest e test table-driven ti permette di verificare auth, validazione, codici di stato e casi limite prima del rilascio.

Testare gli handler REST in Go: httptest e test table-driven

Cosa dovresti avere sotto controllo prima del rilascio

Un handler REST può compilare, superare un controllo manuale rapido e comunque fallire in produzione. La maggior parte dei guasti non sono problemi di sintassi. Sono problemi di contratto: l'handler accetta ciò che dovrebbe rifiutare, restituisce il codice di stato sbagliato o perde dettagli in un errore.

I test manuali aiutano, ma è facile perdere casi limite e regressioni. Provi il percorso felice, forse un errore ovvio, e vai avanti. Poi una piccola modifica alla validazione o al middleware rompe silenziosamente un comportamento che davi per scontato.

L'obiettivo dei test degli handler è semplice: rendere ripetibili le promesse dell'handler. Questo include regole di autenticazione, validazione degli input, codici di stato prevedibili e corpi di errore su cui i client possono fare affidamento.

Il pacchetto httptest di Go è perfetto perché puoi esercitare un handler direttamente senza avviare un server reale. Costruisci una richiesta HTTP, la passi all'handler e ispezioni il corpo della risposta, le intestazioni e il codice di stato. I test restano veloci, isolati e facili da eseguire a ogni commit.

Prima del rilascio dovresti sapere (non sperare) che:

  • Il comportamento di autenticazione è coerente per token mancanti, token invalidi e ruoli sbagliati.
  • Gli input sono validati: campi richiesti, tipi, range e (se lo imponi) campi sconosciuti.
  • I codici di stato corrispondono al contratto (per esempio, 401 vs 403, 400 vs 422).
  • Le risposte di errore sono sicure e coerenti (niente stack trace, stessa forma ogni volta).
  • I percorsi non felici sono gestiti: timeout, errori a valle e risultati vuoti.

Un endpoint “Crea ticket” potrebbe funzionare quando invii JSON perfetto come admin. I test catturano quello che ti dimentichi di provare: token scaduto, un campo in più inviato per sbaglio dal client, una priorità negativa o la differenza tra “non trovato” e “errore interno” quando una dipendenza fallisce.

Definisci il contratto per ogni endpoint

Scrivi prima cosa l'handler si impegna a fare, poi scrivi i test. Un contratto chiaro mantiene i test focalizzati e impedisce che diventino congetture su cosa il codice “volesse” fare. Rende anche le refactor più sicure perché puoi cambiare l'implementazione senza cambiare il comportamento pubblico.

Inizia dagli input. Sii specifico su dove viene ogni valore e cosa è richiesto. Un endpoint può prendere un id dal path, limit dalla query string, un header Authorization e un body JSON. Annota le regole che contano: formati ammessi, valori min/max, campi richiesti e cosa succede quando manca qualcosa.

Poi definisci gli output. Non fermarti a “restituisce JSON”. Decidi come appare il successo, quali intestazioni contano e come sono gli errori. Se i client dipendono da codici di errore stabili e da una forma JSON prevedibile, trattalo come parte del contratto.

Una checklist pratica:

  • Input: valori da path/query, header richiesti, campi JSON e regole di validazione
  • Output: codice di stato, intestazioni di risposta, forma JSON per successo ed errore
  • Effetti collaterali: quali dati cambiano e cosa viene creato
  • Dipendenze: chiamate al DB, servizi esterni, tempo corrente, ID generati

Decidi anche fino a dove arrivano i test degli handler. I test degli handler sono più efficaci al confine HTTP: auth, parsing, validazione, codici di stato e corpi di errore. Sposta preoccupazioni più profonde nei test di integrazione: query al database reale, chiamate di rete e routing completo.

Se il tuo backend è generato (per esempio, AppMaster produce handler Go e logica di business), un approccio contract-first è ancora più utile. Puoi rigenerare il codice e verificare che ogni endpoint mantenga lo stesso comportamento pubblico.

Configura un harness minimo con httptest

Un buon test di handler dovrebbe sembrare l'invio di una richiesta reale, senza avviare un server. In Go, questo di solito significa: costruire una richiesta con httptest.NewRequest, catturare la risposta con httptest.NewRecorder e chiamare il tuo handler.

Chiamare l'handler direttamente dà test veloci e mirati. Questo è ideale quando vuoi validare il comportamento dentro l'handler: controlli di auth, regole di validazione, codici di stato e corpi di errore. Usare un router nei test è utile quando il contratto dipende da parametri di path, matching delle rotte o ordine dei middleware. Parti dalle chiamate dirette e aggiungi il router solo quando serve.

Le intestazioni contano più di quanto molti pensino. Un Content-Type mancante può cambiare come l'handler legge il body. Imposta le intestazioni che ti aspetti in ogni caso così i fallimenti indicano la logica e non la configurazione del test.

Ecco un pattern minimo riutilizzabile:

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()

Per mantenere le asserzioni coerenti, aiuta usare un piccolo helper per leggere e decodificare il corpo della risposta. Nella maggior parte dei test, controlla prima il codice di stato (così i fallimenti sono facili da scorrere), poi le intestazioni chiave che prometti (spesso Content-Type), quindi il body.

Se il tuo backend è generato (incluso un backend Go prodotto da AppMaster), questo harness vale comunque. Stai testando il contratto HTTP su cui gli utenti fanno affidamento, non lo stile del codice sottostante.

Progetta casi table-driven leggibili

I test table-driven funzionano meglio quando ogni caso si legge come una piccola storia: la richiesta che invii e cosa ti aspetti indietro. Dovresti poter scorrere la tabella e capire la copertura senza saltare nel file.

Un caso solido di solito ha: un nome chiaro, la richiesta (metodo, path, header, body), il codice di stato atteso e un controllo sulla risposta. Per i body JSON, preferisci asserire pochi campi stabili (come un codice di errore) invece di confrontare l'intera stringa JSON, a meno che il contratto non esiga un output rigoroso.

Una forma semplice di caso riutilizzabile

Tieni la struct del caso focalizzata. Metti setup one-off in helper così la tabella resta piccola.

type tc struct {
	name       string
	method     string
	path       string
	headers    map[string]string
	body       string
	wantStatus int
	wantBody   string // substring or compact JSON
}

Per input diversi, usa piccole stringhe di body che mostrano la differenza a colpo d'occhio: un payload valido, uno con un campo mancante, uno con tipo sbagliato e una stringa vuota. Evita di costruire JSON con molta formattazione nella tabella: diventa rapidamente rumoroso.

Quando vedi setup ripetuto (creazione token, header comuni, body di default), spostalo in helper come newRequest(tc) o baseHeaders().

Se una tabella inizia a mescolare troppe idee, dividila. Una tabella per i percorsi di successo e un'altra per i percorsi di errore spesso è più leggibile e più facile da debug.

Controlli di autenticazione: i casi che spesso vengono saltati

Dal backend alle app
Build web and native mobile apps on the same backend with one no-code workflow.
Build Apps

I test di auth spesso sembrano a posto sul percorso felice, poi fallano in produzione perché un piccolo caso non è stato mai esercitato. Tratta l'autenticazione come un contratto: cosa invia il client, cosa restituisce il server e cosa non deve mai essere rivelato.

Inizia con la presenza e la validità del token. Un endpoint protetto dovrebbe comportarsi diversamente quando l'header manca rispetto a quando è presente ma sbagliato. Se usi token a breve durata, testa anche la scadenza, anche simulandola iniettando un validator che ritorna “expired.”

La maggior parte dei gap è coperta da questi casi:

  • Nessun header Authorization -> 401 con risposta di errore stabile
  • Header malformato (prefisso sbagliato) -> 401
  • Token invalido (firma errata) -> 401
  • Token scaduto -> 401 (o il codice che hai scelto) con messaggio prevedibile
  • Token valido ma ruolo/permessi sbagliati -> 403

La distinzione 401 vs 403 è importante. Usa 401 quando il chiamante non è autenticato. Usa 403 quando è autenticato ma non autorizzato. Se confondi questi casi, i client potrebbero ritentare inutilmente o mostrare l'interfaccia sbagliata.

I controlli di ruolo non bastano per endpoint “posseduti dall'utente” (come GET /orders/{id}). Testa la proprietà: l'utente A non dovrebbe vedere l'ordine dell'utente B nemmeno con un token valido. Questo dovrebbe restituire un 403 pulito (o 404, se vuoi nascondere l'esistenza) e il corpo non dovrebbe trapelare nulla. Mantieni l'errore generico. Non suggerire “l'ordine appartiene all'utente 42.”

Regole sugli input: valida, rifiuta e spiega chiaramente

Molti bug pre-rilascio sono bug sugli input: campi mancanti, tipi sbagliati, formati inaspettati o payload troppo grandi.

Nomina ogni input che l'handler accetta: campi del body JSON, parametri di query e parametri di path. Per ciascuno, decidi cosa succede quando è mancante, vuoto, malformato o fuori range. Poi scrivi casi che dimostrino che l'handler rifiuta input errati presto e restituisce lo stesso tipo di errore ogni volta.

Un piccolo set di casi di validazione copre di solito la maggior parte del rischio:

  • Campi richiesti: mancante vs stringa vuota vs null (se consenti null)
  • Tipi e formati: numero vs stringa, formati email/data/UUID, parsing booleano
  • Limiti di dimensione: lunghezza massima, massimo elementi, payload troppo grande
  • Campi sconosciuti: ignorati vs rifiutati (se imponi decoding strict)
  • Query e path params: mancanti, non parsabili e comportamento di default

Esempio: un handler POST /users accetta { "email": "...", "age": 0 }. Testa email mancante, email come 123, email come "not-an-email", age come -1 e age come "20". Se richiedi JSON strict, testa anche { "email":"[email protected]", "extra":"x" } e conferma che fallisce.

Rendi i fallimenti di validazione prevedibili. Scegli un codice di stato per gli errori di validazione (alcune squadre usano 400, altre 422) e mantieni la forma del corpo di errore coerente. I test devono asserire sia lo status sia un messaggio (o un campo details) che indichi esattamente l'input che ha fallito.

Codici di stato e corpi di errore: rendili prevedibili

Trasforma i contratti in codice
Build Go backends with visual logic and keep endpoints consistent as requirements change.
Try AppMaster

I test degli handler diventano più semplici quando i fallimenti API sono noiosi e coerenti. Vuoi che ogni errore corrisponda a un codice di stato chiaro e restituisca la stessa forma JSON, indipendentemente da chi ha scritto l'handler.

Inizia con una piccola mappatura concordata tra tipi di errore e codici HTTP:

  • 400 Bad Request: JSON malformato, query param richiesti mancanti
  • 404 Not Found: l'ID della risorsa non esiste
  • 409 Conflict: vincolo unico o conflitto di stato
  • 422 Unprocessable Entity: JSON valido ma fallisce regole di business
  • 500 Internal Server Error: fallimenti inaspettati (db giù, nil pointer, outage di terzi)

Poi mantieni il corpo di errore stabile. Anche se il testo del messaggio cambia, i client dovrebbero comunque trovare campi prevedibili su cui contare:

{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }

Nei test, verifica la forma e non solo la linea di stato. Un errore comune è restituire HTML, testo plain o un corpo vuoto sugli errori, il che rompe i client e nasconde i bug.

Verifica anche intestazioni e codifica per le risposte di errore:

  • Content-Type è application/json (e charset coerente se lo imposti)
  • Il body è JSON valido anche in caso di errore
  • code, message e details esistono (details può essere vuoto, ma non casuale)
  • Panics ed errori inaspettati ritornano un 500 sicuro senza esporre stack trace

Se aggiungi un middleware di recover, includi un test che forzi un panic e confermi che ottieni comunque una risposta JSON pulita.

Casi limite: fallimenti, tempo e percorsi non felici

Aggiungi l'autenticazione senza incertezze
Use built-in authentication modules and verify 401 vs 403 behavior with stable contracts.
Add Auth

I test del percorso felice dimostrano che l'handler funziona una volta. I test dei casi limite dimostrano che continua a comportarsi quando il mondo è incerto.

Forza le dipendenze a fallire in modi specifici e ripetibili. Se il tuo handler chiama un database, cache o API esterna, vuoi vedere cosa succede quando quei livelli restituiscono errori che non controlli.

Questi sono utili da simulare almeno una volta per endpoint:

  • Timeout da una chiamata a valle (context deadline exceeded)
  • Not found dallo storage quando il client si aspettava dati
  • Violazione di vincolo unico su create (email duplicata, slug duplicato)
  • Errore di rete o trasporto (connection refused, broken pipe)
  • Errore interno inaspettato (genericamente “qualcosa è andato storto”)

Mantieni i test stabili controllando tutto ciò che può variare tra le esecuzioni. Un test flaky è peggiore di nessun test perché abitua le persone a ignorare i fallimenti.

Rendi prevedibile tempo e random

Se l'handler usa time.Now(), ID o valori random, iniettali. Passa una funzione clock e un generatore di ID nell'handler o nel servizio. Nei test ritorna valori fissi così puoi asserire campi JSON e intestazioni esatti.

Usa piccoli fake e asserisci "nessun effetto collaterale"

Preferisci piccoli fake o stub invece di mock complessi. Un fake può registrare le chiamate e permetterti di asserire che nulla è successo dopo un fallimento.

Per esempio, in un handler “create user”, se l'inserimento nel DB fallisce con un errore di vincolo unico, asserisci il codice di stato corretto, il corpo di errore stabile e che non sia stata inviata alcuna email di benvenuto. Il tuo fake mailer può esporre un contatore (sent=0) così il percorso di fallimento dimostra che non ha attivato effetti collaterali.

Errori comuni che rendono i test degli handler inaffidabili

I test degli handler spesso falliscono per il motivo sbagliato. La richiesta che costruisci in un test non ha la stessa forma di una richiesta reale del client. Questo porta a fallimenti rumorosi e a false sicurezze.

Un problema comune è inviare JSON senza le intestazioni che l'handler si aspetta. Se il tuo codice verifica Content-Type: application/json, non metterlo può far sì che l'handler salti il decoding JSON, ritorni un codice diverso o prenda un ramo che non succede in produzione. Lo stesso vale per l'auth: un header Authorization mancante non è la stessa cosa di un token invalido. Dovrebbero essere casi distinti.

Un'altra trappola è asserire l'intero JSON di risposta come stringa grezza. Piccole modifiche come l'ordine dei campi, gli spazi o nuovi campi rompono i test anche quando l'API è corretta. Decodifica il body in una struct o in map[string]any, poi asserisci ciò che conta: status, codice di errore, messaggio e un paio di campi chiave.

I test diventano inaffidabili anche quando i casi condividono stato mutabile. Riutilizzare lo stesso store in memoria, variabili globali o un router singleton tra le righe della tabella può far trapelare dati tra i casi. Ogni caso di test dovrebbe partire pulito o resettare lo stato in t.Cleanup.

Pattern che di solito causano test fragili:

  • Costruire richieste senza le stesse intestazioni e codifiche che usano i client reali
  • Asserire stringhe JSON complete invece di decodificare e verificare campi
  • Riutilizzare stato condiviso (DB/cache/global handler) tra i casi
  • Accorpare auth, validazione e logica business in un solo test sovradimensionato

Mantieni ogni test focalizzato. Se un caso fallisce, dovresti sapere se è auth, regole di input o formattazione dell'errore in pochi secondi.

Una rapida checklist pre-rilascio riutilizzabile

Standardizza le risposte di errore
Set a standard JSON error shape across services and keep it stable as you iterate.
Create Project

Prima di spedire, i test dovrebbero provare due cose: l'endpoint segue il suo contratto e fallisce in modi sicuri e prevedibili.

Esegui questi controlli come casi table-driven, facendo in modo che ogni caso verifichi sia la risposta sia eventuali effetti collaterali:

  • Auth: nessun token, token errato, ruolo sbagliato, ruolo corretto (e conferma che il caso “ruolo sbagliato” non trapeli dettagli)
  • Input: campi richiesti mancanti, tipi sbagliati, limiti di dimensione (min/max), campi sconosciuti che vuoi rifiutare
  • Output: codice di stato, intestazioni chiave (come Content-Type), campi JSON richiesti, forma d'errore coerente
  • Dipendenze: forza un fallimento a valle (DB, queue, pagamento, email), verifica un messaggio sicuro, conferma assenza di scritture parziali
  • Idempotenza: ripeti la stessa richiesta (o ritenta dopo un timeout) e conferma che non crei duplicati

Dopo ciò, aggiungi una asserzione di sanità che spesso si salta: conferma che l'handler non ha toccato ciò che non doveva. Per esempio, in un caso di validazione fallita, verifica che non sia stato creato alcun record e che non sia stata inviata alcuna email.

Se costruisci API con uno strumento come AppMaster, questa stessa checklist si applica. Il punto è lo stesso: dimostrare che il comportamento pubblico resta stabile.

Esempio: un endpoint, una piccola tabella e cosa cattura

Supponiamo di avere un endpoint semplice: POST /login. Accetta JSON con email e password. Restituisce 200 con un token in caso di successo, 400 per input non validi, 401 per credenziali sbagliate e 500 se il servizio di auth è giù.

Una tabella compatta come questa copre la maggior parte dei casi che rompono in produzione.

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 {
		tt.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)
			}
		})
	}
}

Segui un caso end-to-end: per “missing password” invii un body con solo email, imposti Content-Type, lo esegui con ServeHTTP, poi asserisci 400 e un errore che indica chiaramente password. Quel singolo caso verifica che decoder, validator e formato di errore lavorino insieme.

Se vuoi un modo più veloce per standardizzare contratti, moduli auth e integrazioni mantenendo codice Go reale, AppMaster (appmaster.io) è pensato per questo. Anche allora, questi test restano preziosi perché fissano il comportamento su cui i client fanno affidamento.

Facile da avviare
Creare qualcosa di straordinario

Sperimenta con AppMaster con un piano gratuito.
Quando sarai pronto potrai scegliere l'abbonamento appropriato.

Iniziare