Go REST‑Handler testen: httptest und table‑driven Checks
Go REST‑Handler mit httptest und table‑driven Tests zu prüfen gibt dir eine reproduzierbare Möglichkeit, Auth, Validierung, Statuscodes und Randfälle vor dem Release abzusichern.

Worum du vor dem Release sicher sein solltest
Ein REST‑Handler kann kompilieren, eine schnelle manuelle Prüfung bestehen und trotzdem in Produktion scheitern. Die meisten Fehler sind keine Syntaxfehler. Es sind Vertragsfehler: Der Handler akzeptiert, was er ablehnen sollte, gibt den falschen Statuscode zurück oder leakt Details in einer Fehlermeldung.
Manuelles Testen hilft, aber Randfälle und Regressionen werden leicht übersehen. Du testest den Happy‑Path, vielleicht einen offensichtlichen Fehler, und gehst weiter. Dann bricht eine kleine Änderung in der Validierung oder Middleware stillschweigend ein Verhalten, von dem du ausgegangen bist, dass es stabil ist.
Das Ziel von Handler‑Tests ist einfach: mach die Versprechen des Handlers reproduzierbar. Dazu gehören Auth‑Regeln, Eingabevalidierung, vorhersehbare Statuscodes und Fehlerkörper, auf die Clients sich verlassen können.
Das httptest‑Paket von Go ist dafür ideal, weil du einen Handler direkt ausführen kannst, ohne einen echten Server zu starten. Du baust eine HTTP‑Anfrage, übergibst sie dem Handler und untersuchst Antwortkörper, Header und Statuscode. Tests bleiben schnell, isoliert und lassen sich bei jedem Commit ausführen.
Vor dem Release solltest du wissen (nicht hoffen), dass:
- das Auth‑Verhalten konsistent ist für fehlende Token, ungültige Token und falsche Rollen.
- Eingaben validiert werden: Pflichtfelder, Typen, Bereiche und (falls durchgesetzt) unbekannte Felder.
- Statuscodes dem Vertrag entsprechen (z. B. 401 vs 403, 400 vs 422).
- Fehlerantworten sicher und konsistent sind (keine Stacktraces, immer dieselbe Form).
- Nicht‑Happy‑Paths behandelt werden: Timeouts, Fehler downstream und leere Ergebnisse.
Ein „Ticket erstellen“-Endpoint kann funktionieren, wenn du perfektes JSON als Admin sendest. Tests fangen Dinge ab, die du vergisst zu probieren: ein abgelaufenes Token, ein zusätzliches Feld, das der Client versehentlich schickt, eine negative Priorität oder der Unterschied zwischen „nicht gefunden“ und „interner Fehler“, wenn eine Abhängigkeit ausfällt.
Definiere den Vertrag für jeden Endpoint
Schreibe auf, was der Handler verspricht, zu tun, bevor du Tests schreibst. Ein klarer Vertrag hält die Tests fokussiert und verhindert, dass sie zu Vermutungen darüber werden, was der Code „gemeint“ hat. Er macht Refactorings sicherer, weil du die Interna ändern kannst, ohne das Verhalten zu verändern.
Beginne mit den Eingaben. Sei konkret, woher jeder Wert kommt und was erforderlich ist. Ein Endpoint kann eine id aus dem Pfad, limit aus der Query, einen Authorization‑Header und einen JSON‑Body erwarten. Notiere die relevanten Regeln: erlaubte Formate, Min/Max‑Werte, Pflichtfelder und was passiert, wenn etwas fehlt.
Definiere dann die Ausgaben. Hör nicht bei „gibt JSON zurück“ auf. Entscheide, wie Erfolg aussieht, welche Header wichtig sind und wie Fehler aussehen. Wenn Clients von stabilen Fehlercodes und einer vorhersehbaren JSON‑Form abhängen, behandle das als Teil des Vertrags.
Eine praktische Checkliste ist:
- Eingaben: Pfad/Query‑Werte, erforderliche Header, JSON‑Felder und Validierungsregeln
- Ausgaben: Statuscode, Antwort‑Header, JSON‑Form für Erfolg und Fehler
- Seiteneffekte: welche Daten sich ändern und was erstellt wird
- Abhängigkeiten: Datenbankaufrufe, externe Dienste, aktuelle Zeit, generierte IDs
Entscheide auch, wo Handler‑Tests enden. Handler‑Tests sind am stärksten an der HTTP‑Schnittstelle: Auth, Parsing, Validierung, Statuscodes und Fehlerkörper. Tiefergehende Bedenken verschiebst du in Integrationstests: echte Datenbankabfragen, Netzwerkaufrufe und vollständiges Routing.
Wenn dein Backend generiert ist (zum Beispiel wenn AppMaster Go‑Handler und Business‑Logik erzeugt), ist ein Vertrag‑zuerst‑Ansatz noch nützlicher. Du kannst Code neu generieren und trotzdem prüfen, dass jeder Endpoint dasselbe öffentliche Verhalten behält.
Richte ein minimales httptest‑Harnisch ein
Ein guter Handler‑Test sollte sich anfühlen wie eine echte Anfrage, ohne einen Server zu starten. In Go bedeutet das meist: baue die Anfrage mit httptest.NewRequest, fange die Antwort mit httptest.NewRecorder ab und rufe deinen Handler auf.
Den Handler direkt aufzurufen liefert schnelle, fokussierte Tests. Das ist ideal, wenn du Verhalten innerhalb des Handlers überprüfen willst: Auth‑Checks, Validierungsregeln, Statuscodes und Fehlerkörper. Nutze einen Router in Tests nur dann, wenn der Vertrag von Pfadparametern, Route‑Matching oder der Reihenfolge von Middleware abhängt. Fang mit direkten Aufrufen an und füge den Router nur hinzu, wenn nötig.
Header sind wichtiger, als viele denken. Ein fehlender Content-Type kann ändern, wie der Handler den Body liest. Setze die Header, die du in jedem Fall erwartest, damit Fehlermeldungen auf Logik und nicht auf Test‑Setup hinweisen.
Hier ein minimalistisches Pattern, das du wiederverwenden kannst:
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()
Um Assertions konsistent zu halten, hilft ein kleiner Helfer, der den Antwortkörper liest und decodiert. In den meisten Tests prüfe zuerst den Statuscode (damit Fehler leicht zu erkennen sind), dann die wichtigen Header (oft Content-Type) und zuletzt den Body.
Wenn dein Backend generiert ist (einschließlich eines von AppMaster erzeugten Go‑Backends), gilt dieses Harnisch weiterhin. Du testest den HTTP‑Vertrag, auf den sich Nutzer verlassen, nicht den Stil des generierten Codes.
Entwirf table‑driven Fälle, die lesbar bleiben
Table‑driven Tests funktionieren am besten, wenn jeder Fall wie eine kleine Geschichte lesbar ist: die Anfrage, die du sendest, und was du erwartest. Du solltest die Tabelle überfliegen können und die Abdeckung verstehen, ohne durch die Datei zu springen.
Ein solider Fall hat in der Regel: einen klaren Namen, die Anfrage (Methode, Pfad, Header, Body), den erwarteten Statuscode und eine Überprüfung der Antwort. Bei JSON‑Bodies solltest du wenige stabile Felder prüfen (z. B. einen Fehlercode) statt die gesamte JSON‑Zeichenkette zu vergleichen, außer dein Vertrag verlangt strikte Ausgabe.
Eine einfache Fallstruktur, die du wiederverwenden kannst
Halte die Fallstruktur fokussiert. Sonder‑Setup lege in Helferfunktionen, damit die Tabelle klein bleibt.
type tc struct {
name string
method string
path string
headers map[string]string
body string
wantStatus int
wantBody string // Teilstring oder kompaktes JSON
}
Für verschiedene Eingaben nutze kurze Bodies, die den Unterschied auf einen Blick zeigen: ein gültiges Payload, eines mit fehlendem Feld, eines mit falschem Typ und ein leeres String. Vermeide viel formatiertes JSON in der Tabelle – das wird schnell unübersichtlich.
Wenn du wiederholtes Setup siehst (Tokenerstellung, gemeinsame Header, Default‑Body), lege es in Helfer wie newRequest(tc) oder baseHeaders().
Wenn eine Tabelle zu viele Ideen mischt, teile sie auf. Eine Tabelle für Erfolgsfälle und eine andere für Fehlerfälle ist oft leichter zu lesen und zu debuggen.
Auth‑Checks: die Fälle, die oft übersprungen werden
Auth‑Tests sehen oft im Happy‑Path gut aus und versagen dann in Produktion, weil ein „kleiner“ Fall nie getestet wurde. Behandle Auth als Vertrag: was der Client sendet, was der Server zurückgibt und was nie offengelegt werden darf.
Fang mit Token‑Vorhandensein und Gültigkeit an. Ein geschützter Endpoint sollte sich anders verhalten, wenn der Header fehlt, als wenn er vorhanden, aber falsch ist. Wenn du kurzlebige Token nutzt, teste auch Ablaufzeiten, auch wenn du sie durch Injizieren eines Validators simulierst, der „abgelaufen“ zurückgibt.
Die meisten Lücken decken diese Fälle ab:
- Kein
Authorization‑Header → 401 mit stabiler Fehlerantwort - Fehlformatierter Header (falsches Präfix) → 401
- Ungültiges Token (falsche Signatur) → 401
- Abgelaufenes Token → 401 (oder dein gewählter Code) mit vorhersehbarer Meldung
- Gültiges Token, aber falsche Rolle/Permissions → 403
Die Trennung 401 vs 403 ist wichtig. Verwende 401, wenn der Aufrufer nicht authentifiziert ist. Verwende 403, wenn er authentifiziert, aber nicht berechtigt ist. Wenn du das verwischst, werden Clients unnötig erneut versuchen oder die falsche UI zeigen.
Rollentests reichen zudem nicht bei „user‑owned“ Endpoints (z. B. GET /orders/{id}). Teste Eigentumsrechte: Nutzer A sollte die Bestellung von Nutzer B nicht sehen, selbst mit gültigem Token. Das sollte ein klares 403 (oder 404, wenn du die Existenz verbergen willst) sein, und der Body darf nichts leaken. Halte die Fehlermeldung generisch. Sag nicht „Order gehört zu Nutzer 42“.
Eingaberegeln: validieren, ablehnen und klar erklären
Viele Bugs vor dem Release sind Eingabeprobleme: fehlende Felder, falsche Typen, unerwartete Formate oder zu große Payloads.
Nenne jede Eingabe, die dein Handler akzeptiert: JSON‑Felder, Query‑Params und Pfad‑Parameter. Entscheide für jedes, was passiert, wenn es fehlt, leer, fehlerhaft oder außerhalb des Bereichs ist. Schreibe dann Fälle, die zeigen, dass der Handler schlechte Eingaben früh ablehnt und immer denselben Fehler zurückgibt.
Eine kleine Menge an Validierungsfällen deckt meist das meiste Risiko ab:
- Pflichtfelder: fehlt vs leerer String vs null (wenn null erlaubt ist)
- Typen und Formate: Zahl vs String, Email/Datum/UUID Formate, Boolean‑Parsing
- Größenlimits: Max‑Länge, Max‑Items, Payload zu groß
- Unbekannte Felder: ignoriert vs abgelehnt (bei strikt geparstem JSON)
- Query und Pfad‑Parameter: fehlt, nicht parsbar und Default‑Verhalten
Beispiel: Ein POST /users Handler akzeptiert { "email": "...", "age": 0 }. Teste email fehlt, email als 123, email als "not-an-email", age als -1 und age als "20". Wenn du strikt JSON verlangst, teste auch { "email": "[email protected]", "extra": "x" } und bestätige, dass es fehlschlägt.
Mach Validierungsfehler vorhersehbar. Wähle einen Statuscode für Validierungsfehler (manche Teams nutzen 400, andere 422) und halte die Fehlerkörperform konsistent. Tests sollten sowohl Status als auch eine Nachricht (oder ein details‑Feld) prüfen, das auf das genau fehlgeschlagene Eingabefeld hinweist.
Statuscodes und Fehlerkörper: mach sie vorhersehbar
Handler‑Tests werden einfacher, wenn API‑Fehler langweilig und konsistent sind. Jeder Fehler sollte auf einen klaren Statuscode abbilden und dieselbe JSON‑Form zurückgeben, unabhängig davon, wer den Handler schrieb.
Beginne mit einer kleinen, abgestimmten Zuordnung von Fehlertypen zu HTTP‑Statuscodes:
- 400 Bad Request: fehlerhaftes JSON, fehlende erforderliche Query‑Parameter
- 404 Not Found: Ressource mit dieser ID existiert nicht
- 409 Conflict: Unique‑Constraint oder Zustandskonflikt
- 422 Unprocessable Entity: gültiges JSON, das Geschäftsregeln verletzt
- 500 Internal Server Error: unerwartete Fehler (DB down, nil pointer, Drittanbieter‑Ausfall)
Halte dann den Fehlerkörper stabil. Auch wenn sich der Nachrichtentext später ändert, sollten Clients vorhersehbare Felder haben, auf die sie bauen können:
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
In Tests prüfe die Form, nicht nur die Statuszeile. Ein häufiger Fehler ist, dass bei Fehlern HTML, reiner Text oder ein leerer Body zurückkommt, was Clients bricht und Bugs versteckt.
Teste zudem Header und Encoding für Fehlerantworten:
Content-Typeistapplication/json(und Charset ist konsistent, wenn du es setzt)- Body ist gültiges JSON, auch bei Fehlern
code,messageunddetailsexistieren (details kann leer sein, sollte aber nicht zufällig sein)- Panics und unerwartete Fehler geben ein sicheres 500 zurück, ohne Stacktraces zu leaken
Wenn du eine Recover‑Middleware verwendest, füge einen Test hinzu, der eine Panic erzwingt und bestätigt, dass trotzdem eine saubere JSON‑Fehlerantwort kommt.
Randfälle: Ausfälle, Zeit und Nicht‑Happy‑Paths
Happy‑Path‑Tests beweisen, dass der Handler einmal funktioniert. Randfall‑Tests beweisen, dass er sich korrekt verhält, wenn die Welt chaotisch ist.
Erzwinge wiederholbar, dass Abhängigkeiten fehlschlagen. Wenn dein Handler eine Datenbank, einen Cache oder eine externe API aufruft, willst du sehen, was passiert, wenn diese Schichten Fehler zurückgeben, die du nicht kontrollierst.
Diese Fälle sind es wert, mindestens einmal pro Endpoint simuliert zu werden:
- Timeout von einem Downstream‑Call (
context deadline exceeded) - Not Found von Storage, wenn der Client Daten erwartet
- Unique‑Constraint‑Verletzung beim Erstellen (duplizierte Email, duplizierter Slug)
- Netzwerk‑ oder Transportfehler (Connection refused, broken pipe)
- Unerwarteter interner Fehler („something went wrong“)
Halte Tests stabil, indem du alles kontrollierst, was zwischen den Läufen variieren kann. Ein flakiger Test ist schlimmer als kein Test, weil er Leute dazu bringt, Fehler zu ignorieren.
Mache Zeit und Zufall vorhersehbar
Wenn der Handler time.Now(), IDs oder Zufallswerte verwendet, injiziere sie. Übergib eine Clock‑Funktion und einen ID‑Generator in den Handler oder Service. In Tests gib fixe Werte zurück, damit du exakte JSON‑Felder und Header prüfen kannst.
Nutze kleine Fakes und prüfe „keine Seiteneffekte"
Bevorzuge kleine Fakes oder Stubs gegenüber kompletten Mocks. Ein Fake kann Aufrufe protokollieren und dir erlauben zu prüfen, dass nach einem Fehler nichts passiert ist.
Beispiel: In einem „Benutzer erstellen“-Handler, wenn das DB‑Insert mit einem Unique‑Constraint‑Fehler schlägt, prüfe Statuscode, stabilen Fehlerkörper und dass keine Willkommens‑Mail gesendet wurde. Dein Fake‑Mailer kann einen Zähler (sent=0) exposen, sodass der Fehlerpfad beweist, dass keine Seiteneffekte ausgelöst wurden.
Häufige Fehler, die Handler‑Tests unzuverlässig machen
Handler‑Tests schlagen oft aus dem falschen Grund fehl. Die Anfrage, die du im Test baust, hat nicht dieselbe Form wie eine echte Client‑Anfrage. Das führt zu lauten Fehlern und falscher Zuversicht.
Ein häufiger Fehler ist, JSON ohne die Header zu senden, die der Handler erwartet. Wenn dein Code Content-Type: application/json prüft, kann das Vergessen dazu führen, dass der Handler das JSON‑Decoding überspringt, einen anderen Statuscode zurückgibt oder einen Zweig nimmt, der in Produktion nie auftritt. Gleiches gilt für Auth: ein fehlender Authorization‑Header ist nicht dasselbe wie ein ungültiges Token. Das sind unterschiedliche Fälle.
Eine weitere Falle ist, die ganze JSON‑Antwort als Rohstring zu prüfen. Kleine Änderungen wie Feldreihenfolge, Einrückung oder neue Felder brechen Tests, obwohl die API korrekt ist. Decodiere den Body in eine Struct oder map[string]any und prüfe, was wichtig ist: Status, Fehlercode, Nachricht und ein paar Schlüssel‑Felder.
Tests werden auch unzuverlässig, wenn Fälle sich gemeinsamen veränderlichen Zustand teilen. Die Wiederverwendung desselben In‑Memory‑Stores, globaler Variablen oder eines Singleton‑Routers über Table‑Zeilen hinweg kann Daten zwischen Fällen leaken. Jeder Testfall sollte sauber starten oder Zustand in t.Cleanup zurücksetzen.
Muster, die Tests brüchig machen:
- Anfragen bauen ohne dieselben Header und Encoding wie reale Clients
- Ganze JSON‑Strings als Rohtext prüfen statt decodieren und Felder prüfen
- Gemeinsamen Datenbank/Cache/Global‑Handler‑Zustand über Fälle hinweg wiederverwenden
- Auth, Validierung und Geschäftslogik in einem übergroßen Test vermischen
Halte jeden Test fokussiert. Wenn ein Fall fehlschlägt, solltest du innerhalb von Sekunden wissen, ob es Auth, Eingaberegeln oder Fehlerformatierung war.
Eine schnelle Pre‑Release‑Checkliste, die du wiederverwenden kannst
Bevor du auslieferst, sollten Tests zwei Dinge beweisen: der Endpoint hält seinen Vertrag ein, und er schlägt auf sichere, vorhersehbare Weise fehl.
Führe diese Punkte als table‑driven Fälle aus und lass jeden Fall sowohl die Antwort als auch Seiteneffekte prüfen:
- Auth: kein Token, schlechtes Token, falsche Rolle, richtige Rolle (und bestätige, dass der „falsche Rolle“-Fall keine Details leakt)
- Eingaben: fehlende Pflichtfelder, falsche Typen, Grenzgrößen (Min/Max), unbekannte Felder, die du ablehnen willst
- Ausgaben: Statuscode, Schlüssel‑Header (z. B.
Content-Type), erforderliche JSON‑Felder, konsistente Fehlerform - Abhängigkeiten: eine Downstream‑Fehlsituation erzwingen (DB, Queue, Payment, Email), sichere Meldung verifizieren, bestätige keine Teil‑Writes
- Idempotenz: dieselbe Anfrage wiederholen (oder nach Timeout erneut versuchen) und bestätigen, dass keine Duplikate entstehen
Füge danach eine einfache Sanity‑Assertion hinzu, die du überspringen kannst: bestimme, dass der Handler nichts berührt hat, was er nicht berühren sollte. Zum Beispiel: bei einer Validierungsfehlermeldung bestätige, dass kein Datensatz erstellt und keine E‑Mail gesendet wurde.
Wenn du APIs mit einem Tool wie AppMaster (appmaster.io) baust, gilt dieselbe Checkliste. Der Punkt bleibt: belege, dass das öffentliche Verhalten stabil bleibt.
Beispiel: ein Endpoint, eine kleine Tabelle und was sie auffängt
Angenommen, du hast einen einfachen Endpoint: POST /login. Er akzeptiert JSON mit email und password. Er gibt 200 mit einem Token bei Erfolg, 400 für ungültige Eingaben, 401 bei falschen Zugangsdaten und 500, wenn der Auth‑Service down ist.
Eine kompakte Tabelle wie diese deckt die meisten Produktionsprobleme ab.
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)
}
})
}
}
Gehe einen Fall komplett durch: für „missing password“ sendest du einen Body mit nur email, setzt Content-Type, führst ihn durch ServeHTTP und prüfst dann 400 und einen Fehler, der klar auf password hinweist. Dieser Fall beweist Decoder, Validator und Fehlerformat zusammen.
Wenn du schneller Verträge, Auth‑Module und Integrationen standardisieren möchtest, ist AppMaster (appmaster.io) dafür gemacht. Auch dann bleiben diese Tests wertvoll, weil sie das Verhalten deiner Clients absichern.


