60. Testing beyond tables: fuzzing, httptest, golden files, integration tests
60.1 httptest for real handler tests
func TestHandleHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
handler := http.HandlerFunc(handleHealth)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("Content-Type = %q", ct)
}
}
Use httptest.NewServer when you need a real URL and network client:
srv := httptest.NewServer(http.HandlerFunc(handle))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
60.2 Golden files for large outputs
Golden files are useful when the output is too large to read comfortably in the test body: rendered HTML, generated config, CLI output, SQL, JSON.
func TestRender(t *testing.T) {
got := RenderReport(sampleReport())
want, err := os.ReadFile("testdata/report.golden")
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(string(want), got); diff != "" {
t.Fatal(diff)
}
}
Keep golden files under testdata/; the Go tool ignores that directory for
normal package builds.
60.3 Fuzz tests for parsers and boundary-heavy code
func FuzzParseSize(f *testing.F) {
f.Add("10KB")
f.Add("")
f.Add("999999999999999999999TB")
f.Fuzz(func(t *testing.T, input string) {
_, _ = ParseSize(input) // must not panic
})
}
Run:
go test -fuzz=FuzzParseSize ./...
Fuzzing shines for decoders, parsers, validators, path normalization, and anything that handles untrusted input.
60.4 Integration tests without slowing every unit test
Use build tags or environment variables for tests that need Docker, Postgres, Redis, cloud credentials, or network access:
//go:build integration
func TestPostgresStore(t *testing.T) {
if testing.Short() {
t.Skip("integration test")
}
// start/use real database
}
go test ./...
go test -tags=integration ./...
The professional habit is a pyramid: many fast unit tests, fewer integration tests, and a small number of end-to-end smoke tests that prove the deployed binary actually works.
60.5 Testing time and concurrency
Avoid tests that depend on real sleeps:
time.Sleep(500 * time.Millisecond) // flaky: machine speed and scheduler dependent
Prefer injected clocks/timers or synchronization:
done := make(chan struct{})
go func() {
defer close(done)
doWork()
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
Timeouts in tests are safety nets, not the synchronization mechanism.