BuildBot

From Source to Service

Testing Go services

Lesson 4 of 5

What you'll learn

  • Write table-driven tests with t.Run subtests — the idiomatic Go pattern
  • Test HTTP handlers with httptest.NewRecorder and httptest.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.

Table-driven subtests (JS model)

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.

Loading editor…
Knowledge check

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.