Testing Handlers
Testing Handlers
Section titled “Testing Handlers”This guide shows how to test Plumego handlers using the standard library’s net/http/httptest package. Because every Plumego handler is a plain http.HandlerFunc, no framework-specific test helpers are required.
What this guide covers
Section titled “What this guide covers”- Testing a handler that reads a query parameter
- Testing a handler that uses an injected dependency
- Verifying
contract.WriteErrorandcontract.WriteResponseoutput - Checking the response body and status code
Step 1 — Test a handler that reads a query param
Section titled “Step 1 — Test a handler that reads a query param”The Greet handler from the reference service:
// handler under testfunc (h APIHandler) Greet(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { _ = contract.WriteError(w, r, contract.NewErrorBuilder(). Type(contract.TypeRequired). Detail("field", "name"). Message("name is required"). Build()) return } _ = contract.WriteResponse(w, r, http.StatusOK, greetResponse{Message: "hello, " + name}, nil)}The test:
package handler_test
import ( "encoding/json" "net/http" "net/http/httptest" "testing"
"myapp/internal/handler")
func TestGreet_MissingName(t *testing.T) { h := handler.APIHandler{} r := httptest.NewRequest(http.MethodGet, "/api/v1/greet", nil) w := httptest.NewRecorder()
h.Greet(w, r)
if w.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", w.Code) }}
func TestGreet_WithName(t *testing.T) { h := handler.APIHandler{} r := httptest.NewRequest(http.MethodGet, "/api/v1/greet?name=Alice", nil) w := httptest.NewRecorder()
h.Greet(w, r)
if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } var body struct { Data struct{ Message string } `json:"data"` } if err := json.NewDecoder(w.Body).Decode(&body); err != nil { t.Fatal(err) } if body.Data.Message != "hello, Alice" { t.Errorf("want 'hello, Alice', got %q", body.Data.Message) }}Step 2 — Test a handler with an injected dependency
Section titled “Step 2 — Test a handler with an injected dependency”For handlers that hold a dependency (DB, logger, external client), pass a test double through the handler struct:
type fakeRepo struct { item *Item err error}
func (f *fakeRepo) Find(_ context.Context, _ string) (*Item, error) { return f.item, f.err}
func TestItemHandler_NotFound(t *testing.T) { h := &handler.ItemHandler{ Repo: &fakeRepo{err: repo.ErrNotFound}, } r := httptest.NewRequest(http.MethodGet, "/api/items?id=999", nil) w := httptest.NewRecorder()
h.Get(w, r)
if w.Code != http.StatusNotFound { t.Fatalf("want 404, got %d", w.Code) }}Step 3 — Parse contract response bodies
Section titled “Step 3 — Parse contract response bodies”contract.WriteResponse wraps the data in {"data": ...}. contract.WriteError uses {"error": ...}. Account for this in assertions:
// Success responsevar success struct { Data json.RawMessage `json:"data"`}json.NewDecoder(w.Body).Decode(&success)
// Error responsevar errResp struct { Error struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"`}json.NewDecoder(w.Body).Decode(&errResp)Step 4 — Table-driven tests for multiple scenarios
Section titled “Step 4 — Table-driven tests for multiple scenarios”For handlers with several input variations, prefer table-driven tests over repeated test functions:
func TestGreet(t *testing.T) { h := handler.APIHandler{}
tests := []struct { name string query string status int body string }{ {"missing name", "/api/v1/greet", http.StatusBadRequest, ""}, {"alice", "/api/v1/greet?name=Alice", http.StatusOK, "hello, Alice"}, {"bob", "/api/v1/greet?name=Bob", http.StatusOK, "hello, Bob"}, }
for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, tc.query, nil) w := httptest.NewRecorder() h.Greet(w, r)
if w.Code != tc.status { t.Fatalf("want status %d, got %d", tc.status, w.Code) } if tc.body != "" && !strings.Contains(w.Body.String(), tc.body) { t.Errorf("want body to contain %q, got %s", tc.body, w.Body.String()) } }) }}Step 5 — Test middleware in isolation
Section titled “Step 5 — Test middleware in isolation”Middleware takes and returns http.Handler, so it is also directly testable:
func TestRecovery_Panic(t *testing.T) { logger := log.NewLogger() mw, err := recovery.Middleware(recovery.Config{Logger: logger}) if err != nil { t.Fatalf("configure recovery middleware: %v", err) }
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("deliberate panic") })
r := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder()
mw(panicHandler).ServeHTTP(w, r)
if w.Code != http.StatusInternalServerError { t.Fatalf("want 500, got %d", w.Code) }}If this does not work
Section titled “If this does not work”| Symptom | Check first |
|---|---|
| Status code is unexpected | Assert w.Code after the handler runs, not before |
| Response body assertion fails | Decode the contract envelope: success under data, errors under error |
| Handler needs path params or request IDs | Build the request context the same way the router or middleware would before calling the handler |
| Dependency behavior is hard to control | Pass test doubles through handler fields or interfaces |
| Middleware test misses the final handler | Call mw(next).ServeHTTP(w, r) and make next record that it ran |
What this pattern gives you
Section titled “What this pattern gives you”- No test framework or HTTP server required —
httptest.NewRecordercaptures the full response. - Handler structs with interface fields accept any test double without reflection or mocking libraries.
- Tests run fast: there is no listening socket, no network round-trip, no OS resources.
- The same
http.HandlerFuncshape works in production, tests, and benchmarks without adaptation.
Complete example in the reference app
Section titled “Complete example in the reference app”reference/standard-service/internal/handler/api.go shows handler structs with injected dependencies that follow the same shape covered in this guide. The test files alongside it demonstrate the httptest.NewRecorder pattern in practice.
Read next
Section titled “Read next”- Handle Errors
- Error Reference — error type catalog for asserting error responses in tests
- Contract Primer
- Reference App