Go REST-handlers testen: httptest en tabelgestuurde tests
Het testen van Go REST-handlers met httptest en tabelgestuurde gevallen geeft een herhaalbare manier om authenticatie, validatie, statuscodes en edgecases te controleren vóór release.

Waar je zeker van moet zijn vóór release
Een REST-handler kan compileren, een korte handmatige controle doorstaan en toch falen in productie. De meeste fouten zijn geen syntaxisproblemen. Het zijn contractproblemen: de handler accepteert wat hij zou moeten weigeren, geeft de verkeerde statuscode terug of lekt details in een fout.
Handmatig testen helpt, maar het is gemakkelijk om edgecases en regressies te missen. Je probeert vaak het happy path, misschien één voor de hand ligende fout, en gaat door. Dan breekt een kleine wijziging in validatie of middleware stilletjes gedrag waarvan je dacht dat het stabiel was.
Het doel van handlertests is simpel: maak de beloften van de handler herhaalbaar. Dat omvat authenticatieregels, inputvalidatie, voorspelbare statuscodes en foutlichamen waarop clients veilig kunnen vertrouwen.
Go's httptest-pakket is hiervoor ideaal omdat je een handler direct kunt aanspreken zonder een echte server te starten. Je bouwt een HTTP-request, geeft het aan de handler en inspecteert de response-body, headers en statuscode. Tests blijven snel, geïsoleerd en makkelijk uitvoerbaar bij elke commit.
Vóór release moet je weten (niet hopen) dat:
- Auth-gedrag consistent is voor ontbrekende tokens, ongeldige tokens en verkeerde rollen.
- Invoer gevalideerd wordt: verplichte velden, types, bereik en (als je het afdwingt) onbekende velden.
- Statuscodes overeenkomen met het contract (bijv. 401 vs 403, 400 vs 422).
- Foutantwoorden veilig en consistent zijn (geen stacktraces, steeds dezelfde vorm).
- Niet-happy-paths worden afgehandeld: timeouts, downstream-fouten en lege resultaten.
Een "Create ticket"-endpoint kan werken wanneer je perfecte JSON als admin stuurt. Tests vangen wat je vergeet te proberen: een verlopen token, een extra veld dat de client per ongeluk meestuurt, een negatieve prioriteit, of het verschil tussen "niet gevonden" en "interne fout" wanneer een afhankelijkheid faalt.
Definieer het contract voor elk endpoint
Schrijf op wat de handler belooft te doen voordat je tests schrijft. Een duidelijk contract houdt tests gefocust en voorkomt dat ze gokken naar wat de code "bedoelde". Het maakt refactors ook veiliger omdat je intern kunt veranderen zonder het gedrag te wijzigen.
Begin met inputs. Wees specifiek over waar elke waarde vandaan komt en wat vereist is. Een endpoint kan een id uit het pad halen, limit uit de querystring, een Authorization-header en een JSON-body. Noteer regels die ertoe doen: toegestane formaten, min/max-waarden, verplichte velden en wat gebeurt als iets ontbreekt.
Definieer vervolgens outputs. Stop niet bij "retourneert JSON." Bepaal hoe succes eruitziet, welke headers belangrijk zijn en hoe fouten eruitzien. Als clients afhankelijk zijn van stabiele foutcodes en een voorspelbare JSON-vorm, behandel dat dan als onderdeel van het contract.
Een praktische checklist is:
- Inputs: pad-/querywaarden, vereiste headers, JSON-velden en validatieregels
- Outputs: statuscode, response-headers, JSON-vorm voor succes en fout
- Bijwerkingen: welke data verandert en wat er wordt aangemaakt
- Afhankelijkheden: database-aanroepen, externe services, huidige tijd, gegenereerde IDs
Bepaal ook waar handlertests stoppen. Handlertests zijn het krachtigst op de HTTP-grens: auth, parsing, validatie, statuscodes en foutlichamen. Schuif diepere zorgen naar integratietests: echte databasequery's, netwerkoproepen en volledige routing.
Als je backend gegenereerd is (bijvoorbeeld AppMaster produceert Go-handlers en businesslogic), is een contract-first benadering nog nuttiger. Je kunt code regenereren en toch verifiëren dat elk endpoint hetzelfde publieke gedrag behoudt.
Zet een minimale httptest-harnas op
Een goede handlertest moet voelen als het versturen van een echt request, zonder een server te starten. In Go betekent dat meestal: bouw een request met httptest.NewRequest, vang de response met httptest.NewRecorder en roep je handler aan.
De handler direct aanroepen geeft snelle, gerichte tests. Dit is ideaal wanneer je gedrag binnen de handler wilt valideren: auth-checks, validatieregels, statuscodes en foutlichamen. Het gebruik van een router in tests is nuttig wanneer het contract afhangt van path-params, route-matching of middleware-volgorde. Begin met directe aanroepen en voeg de router alleen toe als je het nodig hebt.
Headers zijn belangrijker dan de meeste mensen denken. Een ontbrekende Content-Type kan veranderen hoe de handler de body leest. Stel de headers in die je in elk geval verwacht, zodat fouten naar logica wijzen en niet naar testsetup.
Hier is een minimaal patroon dat je kunt hergebruiken:
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()
Om asserties consistent te houden, helpt één kleine helper om de response-body te lezen en te decoderen. Controleer in de meeste tests eerst de statuscode (zodat failures makkelijk te scannen zijn), daarna de belangrijke headers die je belooft (vaak Content-Type) en vervolgens de body.
Als je backend gegenereerd is (inclusief een Go-backend geproduceerd door AppMaster), geldt dit harnas nog steeds. Je test het HTTP-contract waar gebruikers op vertrouwen, niet de code-stijl erachter.
Ontwerp tabelgestuurde cases die leesbaar blijven
Tabelgestuurde tests werken het beste wanneer elk geval leest als een klein verhaaltje: het request dat je stuurt en wat je terugverwacht. Je moet de tabel kunnen scannen en de dekking begrijpen zonder door het bestand te springen.
Een solide case heeft meestal: een duidelijke naam, het request (method, pad, headers, body), de verwachte statuscode en een check voor de response. Voor JSON-bodies geef de voorkeur aan het controleren van een paar stabiele velden (zoals een foutcode) in plaats van de hele JSON-string te matchen, tenzij je contract strikte output vereist.
Een eenvoudige case-structuur die je kunt hergebruiken
Houd de case-struct klein. Zet eenmalige setup in helpers zodat de tabel compact blijft.
type tc struct {
name string
method string
path string
headers map[string]string
body string
wantStatus int
wantBody string // substring or compact JSON
}
Voor verschillende inputs gebruik je korte bodystrings die het verschil in één oogopslag tonen: een valide payload, één met een missend veld, één met het verkeerde type en één lege string. Vermijd het bouwen van JSON met veel opmaak in de tabel - dat wordt snel rommelig.
Wanneer je herhaalde setup ziet (tokencreatie, gemeenschappelijke headers, default body), verplaats dat naar helpers zoals newRequest(tc) of baseHeaders().
Als één tabel te veel ideeën mengt, splits die dan. Eén tabel voor succespaden en een andere voor foutpaden is vaak makkelijker te lezen en te debuggen.
Auth-checks: de gevallen die vaak worden overgeslagen
Auth-tests zien er vaak prima uit op het happy path, maar falen in productie omdat één "klein" geval nooit is getest. Behandel auth als een contract: wat de client stuurt, wat de server teruggeeft en wat nooit mag worden gelekt.
Begin met token-aanwezigheid en -geldigheid. Een beschermd endpoint moet anders reageren wanneer de header ontbreekt versus aanwezig maar onjuist. Als je kortlevende tokens gebruikt, test ook expiry, zelfs als je dat simuleert door een validator in te spuiten die "verlopen" teruggeeft.
De meeste gaten dekken deze gevallen:
- Geen
Authorization-header -> 401 met een stabiel foutantwoord - Verkeerd gevormde header (verkeerde prefix) -> 401
- Ongeldig token (slechte signature) -> 401
- Verlopen token -> 401 (of je gekozen code) met een voorspelbare boodschap
- Geldig token maar verkeerde rol/permissions -> 403
De 401 vs 403-scheiding is belangrijk. Gebruik 401 wanneer de aanroeper niet geauthenticeerd is. Gebruik 403 wanneer ze wel geauthenticeerd zijn maar niet bevoegd. Als je dat vervaagt, zullen clients onnodig opnieuw proberen of de verkeerde UI tonen.
Rolchecks zijn ook niet genoeg bij "user-owned" endpoints (zoals GET /orders/{id}). Test eigendom: gebruiker A mag niet de order van gebruiker B zien, zelfs niet met een geldig token. Dat moet een nette 403 zijn (of 404 als je bestaan bewust verbergt), en het body-antwoord mag geen details lekken. Houd de fout generiek. Zeg niet dat "order behoort tot gebruiker 42."
Invoerrichtlijnen: valideer, weiger en leg duidelijk uit
Veel pre-release bugs zijn inputbugs: missende velden, verkeerde types, onverwachte formaten of te grote payloads.
Noem elke input die je handler accepteert: JSON-bodyvelden, queryparams en pathparams. Bepaal voor elk wat er gebeurt als het ontbreekt, leeg is, verkeerd geformatteerd is of buiten bereik valt. Schrijf vervolgens cases die bewijzen dat de handler slechte input vroegtijdig afwijst en telkens hetzelfde soort fout teruggeeft.
Een kleine set validatiecases dekt meestal het grootste risico:
- Verplichte velden: missend vs lege string vs null (als je null toestaat)
- Types en formaten: nummer vs string, e-mail/datum/UUID-formaten, boolean-parsing
- Groottebeperkingen: max lengte, max items, payload te groot
- Onbekende velden: negeren vs weigeren (als je strict decoding afdwingt)
- Query- en pathparams: missend, niet te parsen en standaardgedrag
Voorbeeld: een POST /users-handler accepteert { "email": "...", "age": 0 }. Test email missend, email als 123, email als "not-an-email", age als -1 en age als "20". Als je strikte JSON vereist, test ook { "email":"[email protected]", "extra":"x" } en bevestig dat het faalt.
Maak validatiefouten voorspelbaar. Kies een statuscode voor validatiefouten (sommige teams gebruiken 400, anderen 422) en houd de foutbody-vorm consistent. Tests moeten zowel de status als een boodschap (of een details-veld) controleren dat wijst naar de exacte invoer die faalde.
Statuscodes en foutlichamen: maak ze voorspelbaar
Handlertests worden makkelijker als API-fouten saai en consistent zijn. Je wilt dat elke fout naar een duidelijke statuscode map en dezelfde JSON-vorm teruggeeft, ongeacht wie de handler schreef.
Begin met een kleine, afgesproken mapping van fouttypen naar HTTP-statuscodes:
- 400 Bad Request: malformed JSON, missende vereiste queryparams
- 404 Not Found: resource-ID bestaat niet
- 409 Conflict: unieke beperking of staatconflict
- 422 Unprocessable Entity: geldige JSON maar faalt businessregels
- 500 Internal Server Error: onverwachte fouten (db down, nil pointer, third-party outage)
Houd daarna het foutbody stabiel. Zelfs als berichttekst later verandert, moeten clients toch voorspelbare velden hebben om op te vertrouwen:
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
In tests controleer je de vorm, niet alleen de statusregel. Een veelvoorkomende fout is HTML, platte tekst of een lege body teruggeven bij fouten, wat clients breekt en bugs verbergt.
Test ook headers en encodering voor foutantwoorden:
Content-Typeisapplication/json(en charset is consistent indien ingesteld)- Body is geldige JSON, ook bij fouten
code,messageendetailsbestaan (details mogen leeg zijn, maar mogen niet willekeurig zijn)- Panics en onverwachte fouten geven een veilige 500 zonder stacktraces te lekken
Als je een recover-middleware toevoegt, neem één test op die een panic forceert en bevestig dat je nog steeds een nette JSON-foutresponse krijgt.
Edgecases: fouten, tijd en niet-happy-paden
Happy-path tests bewijzen dat de handler werkt in één situatie. Edgecase-tests bewijzen dat hij zich houdt wanneer de wereld rommelig is.
Forceer afhankelijkheden om op specifieke, reproduceerbare manieren te falen. Als je handler een database, cache of externe API aanroept, wil je zien wat er gebeurt als die lagen fouten teruggeven die je niet controleert.
Deze zijn het waard om minstens één keer per endpoint te simuleren:
- Timeout van een downstream call (
context deadline exceeded) - Not found uit opslag wanneer de client data verwacht
- Unique constraint violation bij create (duplicate email, duplicate slug)
- Netwerk- of transportfout (connection refused, broken pipe)
- Onverwachte interne fout (generieke "iets ging mis")
Houd tests stabiel door alles te controleren wat tussen runs kan verschillen. Een flapperende test is slechter dan geen test omdat mensen leren faalmeldingen te negeren.
Maak tijd en willekeur voorspelbaar
Als de handler time.Now(), IDs of random waarden gebruikt, injecteer die. Geef een klokfunctie en een ID-generator aan de handler of service. In tests geef je vaste waarden terug zodat je exacte JSON-velden en headers kunt controleren.
Gebruik kleine fakes en controleer "geen bijwerkingen"
Geef de voorkeur aan kleine fakes of stubs boven volledige mocks. Een fake kan oproepen registreren en je laten controleren dat er niets gebeurde na een fout.
Bijvoorbeeld, in een "create user"-handler, als de database-insert faalt door een unieke beperking, controleer dan dat de statuscode correct is, de foutbody stabiel is en dat er geen welkomstmail is verzonden. Je fake-mailer kan een teller (sent=0) blootstellen zodat het foutpad bewijst dat er geen bijwerkingen plaatsvonden.
Veelgemaakte fouten die handlertests onbetrouwbaar maken
Handlertests falen vaak om de verkeerde reden. Het request dat je in een test bouwt is niet hetzelfde als een echt client-request. Dat leidt tot ruis in failures en valse zekerheid.
Een veelvoorkomend probleem is het versturen van JSON zonder de headers die je handler verwacht. Als je code Content-Type: application/json controleert, kan het vergeten daarvan ervoor zorgen dat de handler JSON-decoding overslaat, een andere statuscode teruggeeft of een codepad neemt dat in productie nooit voorkomt. Hetzelfde geldt voor auth: een ontbrekende Authorization-header is niet hetzelfde als een ongeldig token. Dat moeten verschillende cases zijn.
Een andere valkuil is het asserten van de hele JSON-response als raw string. Kleine wijzigingen zoals volgorde van velden, spaties of nieuwe velden breken tests, ook al is de API nog steeds correct. Decodeer de body in een struct of map[string]any en controleer wat belangrijk is: status, foutcode, message en een paar sleutelvelden.
Tests worden ook onbetrouwbaar wanneer cases gedeelde mutable state gebruiken. Hergebruik van dezelfde in-memory store, globale variabelen of een singleton-router over tabelrijen heen kan data tussen cases lekken. Elke testcase moet schoon starten of state resetten in t.Cleanup.
Patronen die meestal broze tests veroorzaken:
- Requests bouwen zonder dezelfde headers en encoding die echte clients gebruiken
- Hele JSON-strings als raw vergelijken in plaats van decoderen en velden controleren
- Hergebruik van gedeelde database/cache/globale handlerstate over cases
- Auth, validatie en businesslogica samenproppen in één te grootse test
Houd elke test gefocust. Als één case faalt, moet je binnen enkele seconden weten of het auth, inputregels of foutformattering is.
Een snelle pre-release checklist die je kunt hergebruiken
Voordat je shipped, moeten tests twee dingen bewijzen: het endpoint volgt zijn contract en faalt op veilige, voorspelbare manieren.
Draai deze als tabelgestuurde cases en laat elke case zowel de response als bijwerkingen controleren:
- Auth: geen token, slecht token, verkeerde rol, correcte rol (en bevestig dat de "verkeerde rol" case geen details lekt)
- Inputs: missende verplichte velden, verkeerde types, grenswaarden (min/max), onbekende velden die je wilt weigeren
- Outputs: statuscode, belangrijke headers (zoals
Content-Type), vereiste JSON-velden, consistente foutvorm - Afhankelijkheden: forceer één downstream-fout (DB, queue, betaling, e-mail), verifieer een veilige boodschap, bevestig geen gedeeltelijke writes
- Idempotentie: herhaal hetzelfde request (of retry na timeout) en bevestig dat je geen duplicaten aanmaakt
Daarna voeg je één sanity-assertie toe die vaak wordt overgeslagen: bevestig dat de handler niet aanraakt wat hij niet zou moeten. Bijvoorbeeld, in een geval met mislukte validatie, controleer dat er geen record is aangemaakt en dat er geen e-mail is verzonden.
Als je APIs bouwt met een tool zoals AppMaster, geldt deze checklist nog steeds. Het punt is hetzelfde: bewijs dat het publieke gedrag stabiel blijft.
Voorbeeld: één endpoint, een kleine tabel en wat het opvangt
Stel dat je een eenvoudig endpoint hebt: POST /login. Het accepteert JSON met email en password. Het geeft 200 met een token bij succes, 400 voor ongeldige input, 401 voor verkeerde credentials en 500 als de auth-service down is.
Een compacte tabel zoals deze dekt het meeste dat in productie breekt.
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 {
t.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)
}
})
}
}
Loop één case van begin tot eind: voor "missing password" stuur je een body met alleen email, zet je Content-Type, run je het via ServeHTTP en controleer je 400 en een fout die duidelijk wijst naar password. Die enkele case bewijst dat je decoder, validator en foutresponse-formaat samenwerken.
Als je een snellere manier wilt om contracten, auth-modules en integraties te standaardiseren terwijl je nog steeds echte Go-code shipped, is AppMaster gebouwd voor dat doel. Zelfs dan blijven deze tests waardevol omdat ze het gedrag vastleggen waarop je clients vertrouwen.


