Testing Strategies: httptest, Fakes & Fuzzing
Prerequisites: Building a REST API
Level 2 covered table-driven unit tests. Real services also need to test HTTP handlers, code that talks to other systems, and inputs you didn't think of. The standard library covers all three.
Testing a handler with httptest.NewRecorder
httptest.NewRecorder is an in-memory http.ResponseWriter. Call the
handler directly — no network, no ports:
// greet.go
package greet
import (
"fmt"
"net/http"
)
func Hello(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "hello, %s", name)
}
// greet_test.go
package greet
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHello(t *testing.T) {
tests := []struct {
name string
url string
wantStatus int
wantBody string
}{
{"with name", "/hello?name=Ada", http.StatusOK, "hello, Ada"},
{"missing name", "/hello", http.StatusBadRequest, "name is required\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
rec := httptest.NewRecorder()
Hello(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if got := rec.Body.String(); got != tt.wantBody {
t.Errorf("body = %q, want %q", got, tt.wantBody)
}
})
}
}
To test routing and middleware too, build your real mux and call
mux.ServeHTTP(rec, req) instead of the handler function.
Testing a client with httptest.NewServer
When the code under test makes HTTP requests, httptest.NewServer starts a
real server on a random local port:
func TestFetchStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
}))
defer srv.Close()
got, err := FetchStatus(srv.URL) // your client code, pointed at the fake
if err != nil {
t.Fatal(err)
}
if got != "ok" {
t.Errorf("got %q, want ok", got)
}
}
This is why client code should take a base URL (or an *http.Client) as a
parameter rather than hard-coding it.
Fakes via small interfaces
For dependencies like email, payments, or a database, define a small interface at the point of use and pass a hand-written fake in tests:
type Mailer interface {
Send(to, subject, body string) error
}
type Signup struct {
Mailer Mailer
}
func (s *Signup) Register(email string) error {
// ... create the user ...
return s.Mailer.Send(email, "Welcome", "Thanks for signing up")
}
type fakeMailer struct {
sent []string
err error
}
func (f *fakeMailer) Send(to, subject, body string) error {
f.sent = append(f.sent, to)
return f.err
}
func TestRegisterSendsWelcome(t *testing.T) {
m := &fakeMailer{}
s := &Signup{Mailer: m}
if err := s.Register("ada@example.com"); err != nil {
t.Fatal(err)
}
if len(m.sent) != 1 || m.sent[0] != "ada@example.com" {
t.Errorf("sent = %v", m.sent)
}
}
A fake is a working lightweight implementation; it's usually clearer than a mocking framework with call expectations. For databases, many teams also run integration tests against a real database in a container.
Fuzzing
A fuzz test feeds generated inputs to your code looking for crashes or
broken invariants. It lives in a _test.go file and takes a *testing.F:
// reverse.go
package text
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// reverse_test.go
package text
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
for _, seed := range []string{"hello", "", "héllo, 世界"} {
f.Add(seed) // seed corpus
}
f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
t.Skip("only valid UTF-8 round-trips through []rune")
}
twice := Reverse(Reverse(s))
if twice != s {
t.Errorf("Reverse(Reverse(%q)) = %q", s, twice)
}
})
}
go test # runs only the seed corpus, like a normal test
go test -fuzz=FuzzReverse -fuzztime=30s
When fuzzing finds a failure it saves the input under
testdata/fuzz/FuzzReverse/; commit that file and it becomes a permanent
regression test.
Golden files
For large outputs (rendered HTML, generated code, CLI output), compare
against a file in testdata/ and add an -update flag to regenerate it:
var update = flag.Bool("update", false, "update golden files")
func TestRender(t *testing.T) {
got := Render(sampleInput)
golden := filepath.Join("testdata", "render.golden")
if *update {
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Errorf("output differs from %s; run go test -update", golden)
}
}
Review golden-file diffs in code review like any other change.
Common mistakes
- Starting a real server on a fixed port in tests — tests collide when run in
parallel. Use
httptest. - Mocking everything, so tests only check that functions were called in a certain order and break on every refactor.
- Defining huge interfaces just to fake them. Keep the interface to the methods the consumer actually uses.
- Forgetting
defer srv.Close()— leaked servers and goroutines.
Practice
Write a test for this health handler that checks the status code, the
Content-Type header, and the body.
func Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
Show solution
package health
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
Health(rec, req)
res := rec.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q", ct)
}
if body := rec.Body.String(); body != `{"status":"ok"}` {
t.Errorf("body = %q", body)
}
}
The handler never calls WriteHeader, so the first Write sends an implicit
200 OK.