Skip to content

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.

  • Testing a handler that reads a query parameter
  • Testing a handler that uses an injected dependency
  • Verifying contract.WriteError and contract.WriteResponse output
  • 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 test
func (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)
}
}

contract.WriteResponse wraps the data in {"data": ...}. contract.WriteError uses {"error": ...}. Account for this in assertions:

// Success response
var success struct {
Data json.RawMessage `json:"data"`
}
json.NewDecoder(w.Body).Decode(&success)
// Error response
var 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())
}
})
}
}

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)
}
}
SymptomCheck first
Status code is unexpectedAssert w.Code after the handler runs, not before
Response body assertion failsDecode the contract envelope: success under data, errors under error
Handler needs path params or request IDsBuild the request context the same way the router or middleware would before calling the handler
Dependency behavior is hard to controlPass test doubles through handler fields or interfaces
Middleware test misses the final handlerCall mw(next).ServeHTTP(w, r) and make next record that it ran
  • No test framework or HTTP server required — httptest.NewRecorder captures 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.HandlerFunc shape works in production, tests, and benchmarks without adaptation.

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.