package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type createThingReq struct {
Name string `json:"name"`
}
func TestCreateThing(t *testing.T) {
cases := []struct {
name string
body string
status int
}{
{name: "missing", body: `{}`, status: http.StatusBadRequest},
{name: "ok", body: `{"name":"demo"}`, status: http.StatusCreated},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/things", strings.NewReader(tc.body))
w := httptest.NewRecorder()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req createThingReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
})
handler.ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatalf("got status %d, want %d", w.Code, tc.status)
}
})
}
}
Go’s table-driven tests are one of the best “boring but effective” practices. For handlers, I use httptest.NewRecorder and httptest.NewRequest so tests run fast without networking. Each case specifies method, path, body, expected status, and sometimes expected JSON keys. The big win is that you can add new edge cases as rows in a table instead of copying/pasting test bodies. I also prefer testing behavior, not implementation: assert status codes, headers, and response shapes rather than internal function calls. When I need stable JSON comparisons, I decode into map[string]any and compare keys. This pattern scales nicely as your API grows and catches regressions early, especially around validation and error handling.