From Source to Service
Testing Go services
Lesson 4 of 5
What you'll learn
- Write table-driven tests with
t.Runsubtests — the idiomatic Go pattern - Test HTTP handlers with
httptest.NewRecorderandhttptest.NewServer - Measure coverage with
go test -cover
Go's test runner ships with the toolchain: no Jest, no Vitest, no config file. Any _test.go file with func TestXxx(t *testing.T) functions runs under go test ./.... There's no expect(...).toBe(...) matcher library either — you compare values and call t.Errorf yourself.
The idiom that replaces describe/it blocks is the table-driven test: a slice of cases, one loop, one subtest per case via t.Run:
func TestSlugify(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"lowercases", "Hello", "hello"},
{"spaces to dashes", "a b c", "a-b-c"},
{"trims", " x ", "x"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Slugify(tc.in); got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
Each t.Run case reports and fails independently, and go test -run TestSlugify/trims targets one case by name. Adding a regression test is adding a row — which is why Go code accumulates tests so cheaply.
Testing handlers with httptest
Because lesson 1's handlers are closures over their dependencies, testing them is just calling them. net/http/httptest provides the two pieces:
func TestGetNote(t *testing.T) {
store := NewNoteStore() // real store, in-memory SQLite
store.Create("1", "ship it")
req := httptest.NewRequest("GET", "/notes/1", nil)
req.SetPathValue("id", "1") // stands in for mux routing
rec := httptest.NewRecorder() // an in-memory ResponseWriter
handleGetNote(store)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
NewRecorder tests one handler with zero network. When you want the whole stack — mux routing, middleware, real TCP — httptest.NewServer(mux) starts an actual server on a random port and hands you its URL to hit with a plain HTTP client. Use the recorder by default; reserve NewServer for routing and integration tests.
Coverage is a flag, not a plugin
go test -cover ./... prints per-package coverage; go test -coverprofile=c.out ./... && go tool cover -html=c.out opens an annotated source view. Treat coverage as a flashlight for untested branches, not a score to chase.
The challenge is a JS model of the table + subtest pattern: cases as data, each run and reported independently, with a failure that doesn't stop the table.
Run it. This models t.Run over a case table: every row runs even after a failure, each reports under its own name, and the summary mirrors go test output. Fix slugify's trim bug if you like.
What does wrapping each table case in t.Run give you?
Next: the service works and the tests pass — compile it into one static binary and put it on a Linux box behind a reverse proxy.
Saved on this device. Sign in to sync your progress everywhere.