Skip to content
AgileLogics
9 min read

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.