Testing It
Fake stores for the rules, httptest for the HTTP edge, and one concurrent test so go test -race has something to find.
go test -race -cover ./...Tests have one job here: to let you change this service next month without reading all of it. Three levels do that, and each is cheap in a different way.
The rules, with a fake store
Service tests need no database and no server. Write a fake store: a small type that satisfies the same interface the real store does, returning whatever the case needs, including an error. Drive the cases from a table, which is a slice of cases run as one subtest each through t.Run. Adding a case is then one line, so nobody skips it.
The HTTP edge, with httptest
httptest.NewRecorder() returns a fake response writer that keeps whatever the handler wrote. httptest.NewRequest(method, target, body) builds a request with no network involved. Together they let you call a handler directly and check the status, the headers and the decoded body. To exercise the router, the middleware and the JSON encoding at once, httptest.NewServer(h) starts a real server on a real port, and srv.Client() returns a client already pointed at it.
The race check
go test -race ./... rebuilds the tests with the race detector, which reports two goroutines touching the same memory without synchronisation. It only sees what actually runs, so a handler that is never called from more than one goroutine in a test is never checked. Add one test that fires many requests at once.
What trips people
Comparing a response body as a string. Field order, whitespace and the newline json.Encoder appends all make an identical document fail. Decode into a struct and compare the values.
Example
// File: notes_test.go — run it with: go test -v -race ./...
// The handler under test is in this file so the example is whole.
package notes
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type Store interface{ Create(text string) error }
type handler struct{ store Store }
func (h handler) create(w http.ResponseWriter, r *http.Request) {
var in struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if strings.TrimSpace(in.Text) == "" {
http.Error(w, "text must not be blank", http.StatusBadRequest)
return
}
if err := h.store.Create(in.Text); err != nil {
// The reason stays in the log; the client gets no detail.
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
// fakeStore lets each case choose what storage does, including failing.
type fakeStore struct{ err error }
func (f fakeStore) Create(text string) error { return f.err }
func TestCreate(t *testing.T) {
tests := []struct {
name string
body string
err error
want int
}{
{name: "created", body: `{"text":"milk"}`, want: http.StatusCreated},
{name: "blank text", body: `{"text":" "}`, want: http.StatusBadRequest},
{name: "bad json", body: `{`, want: http.StatusBadRequest},
{name: "store fails", body: `{"text":"milk"}`, err: errors.New("boom"), want: http.StatusInternalServerError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := handler{store: fakeStore{err: tt.err}}
req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(tt.body))
rec := httptest.NewRecorder()
h.create(rec, req)
if rec.Code != tt.want {
t.Errorf("status = %d, want %d", rec.Code, tt.want)
}
})
}
}
// Output:
// === RUN TestCreate
// === RUN TestCreate/created
// === RUN TestCreate/blank_text
// === RUN TestCreate/bad_json
// === RUN TestCreate/store_fails
// --- PASS: TestCreate (0.00s)
// --- PASS: TestCreate/created (0.00s)
// --- PASS: TestCreate/blank_text (0.00s)
// --- PASS: TestCreate/bad_json (0.00s)
// --- PASS: TestCreate/store_fails (0.00s)
// PASSWhen to use it
- Check every status code a handler can return in one table, with no database and no server running.
- Catch a shared map being written by two requests at once before it corrupts data in production.
- Run the full suite including real SQL in CI, while the same command stays fast on a laptop with no Postgres.
More examples
End to end, through a real server
httptest.NewServer listens on a real port on localhost and serves the handler you give it, so this test goes through the router, every middleware, the JSON encoder and a real HTTP client. That is what catches a route registered with the wrong method, a middleware that swallows the body, or a missing Content-Type. Use one or two of these per service, as the check that the whole thing is plugged together, and keep the fast handler tests for the many cases. srv.Client() matters: it is configured for that server, including its TLS certificate when you use NewTLSServer.
func TestCreateThenGet(t *testing.T) {
a := newApp(newMemStore()) // the whole app, with storage faked
srv := httptest.NewServer(a.routes())
t.Cleanup(srv.Close) // runs even if the test fails early
client := srv.Client() // already pointed at srv.URL
res, err := client.Post(srv.URL+"/notes", "application/json",
strings.NewReader(`{"text":"milk"}`))
if err != nil {
t.Fatalf("post: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusCreated)
}
// Decode, never string-compare: field order and whitespace do not matter.
var created Note
if err := json.NewDecoder(res.Body).Decode(&created); err != nil {
t.Fatalf("decode: %v", err)
}
if created.Text != "milk" {
t.Errorf("text = %q, want %q", created.Text, "milk")
}
if created.ID == "" {
t.Error("id is empty; the store did not assign one")
}
}A concurrent test, so -race has something to see
A real server runs every request in its own goroutine, so any state shared between handlers is touched concurrently in production and never in an ordinary test. This test makes that happen on purpose: fifty requests at once through the same handler and the same store. Run it with go test -race and the detector points at the exact line where two goroutines write the same map or counter. The count at the end catches the other half of the bug, where a map protected by nothing loses writes instead of crashing. Add one test of this shape per piece of shared state.
func TestConcurrentCreates(t *testing.T) {
a := newApp(newMemStore())
h := a.routes()
var wg sync.WaitGroup
for i := range 50 { // range over an int: Go 1.22 and later
wg.Go(func() { // WaitGroup.Go: Go 1.25 and later
body := fmt.Sprintf(`{"text":"note %d"}`, i)
req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
// Errorf, not Fatalf: this is not the test's goroutine.
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
}
})
}
wg.Wait()
if n := a.store.Count(); n != 50 {
t.Errorf("stored %d notes, want 50", n)
}
}The real store, skipped unless a database is there
Fake stores never catch a typo in a column name or a constraint the code violates, so the SQL needs one test against a real Postgres. Gating it on an environment variable keeps go test fast and green on a machine with no database, while the same command runs the full set in CI where TEST_DATABASE_URL is set. The cleanup deletes the row it created, so the test can run twice. t.Context gives a context that is cancelled when the test finishes, which stops a hung query from holding the test binary open. The last check is the important one: it proves the store translates sql.ErrNoRows into the error the handlers actually look for.
func TestPostgresStore(t *testing.T) {
url, ok := os.LookupEnv("TEST_DATABASE_URL")
if !ok {
t.Skip("set TEST_DATABASE_URL to run the store tests")
}
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Logf("close: %v", err)
}
})
ctx := t.Context() // cancelled when this test ends: Go 1.24 and later
st := store.New(db)
n, err := st.Create(ctx, "milk")
if err != nil {
t.Fatalf("create: %v", err)
}
t.Cleanup(func() {
if err := st.Delete(context.Background(), n.ID); err != nil {
t.Logf("delete %s: %v", n.ID, err)
}
})
got, err := st.Get(ctx, n.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Text != "milk" {
t.Errorf("text = %q, want %q", got.Text, "milk")
}
if _, err := st.Get(ctx, "00000000-0000-0000-0000-000000000000"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("missing note: err = %v, want ErrNotFound", err)
}
}
Discussion