Chapter 24's TestCreateTask and TestGetTask repeat the same request-building and assertion steps for every case. This chapter collapses that repetition into Go's idiomatic table-driven pattern, covering more scenarios with less code.
A table-driven test replaces several near-identical test functions with a single slice of cases. A loop then runs each case as its own named subtest. Our tests from Chapter 24 already passed, but adding a new case meant copying and pasting a large block of code. Here, we will rewrite both tests as tables, add two new cases, and explore t.Parallel(), the Go 1.22 loop-variable fix, and go test -cover.

POST /tasks: a valid title, an empty title, and a too-long title. Each expects a specific status and error.GET /tasks/{id}: an existing id, a non-numeric id, and a missing id.TestListTasks keeps its original single-function shape. Adding a table there would only add unnecessary ceremony.A table-driven test is written as a slice of structs. Each struct holds an input and the expected result. A single loop iterates over the slice, running every entry as its own subtest. This is the standard way to test a function against many inputs in Go.
Calling t.Parallel() inside a test or subtest tells that the test can run concurrently with its siblings instead of waiting its turn. This only pays off when the tests share no state.
go testCoverage is the percentage of code statements a test run actually executed, reported by the go test -cover command. It tells you what code ran, but it does not guarantee that the code produced the correct result.
Look back at TestCreateTask from Chapter 24. It had two t.Run blocks, each with its own copy of newTestServer, jsonRequest, and the status and body assertions. The only real differences between them were the task title and the expected result.
A table pulls those differences out into data: a name, title, wantStatus, and wantError. It keeps the request-building and assertion code in exactly one place. Adding the too-long-title case in this chapter only took one new entry in the cases slice. In the old style, we would have copied an entire subtest and hoped the copy stayed in sync with the original.
This is also just how idiomatic Go tests look. If you skim a well-tested Go package on GitHub, you will see this shape: a cases slice of anonymous structs, a for loop, and t.Run(tc.name, ...). Once you recognize it, table-driven tests in any Go codebase read the same way.
TestCreateTask now looks like this:
go// internal/task/handler_test.gofunc TestCreateTask(t *testing.T) {t.Parallel()cases := []struct {name stringtitle stringwantStatus intwantError string}{{name: "valid title returns 201",title: "Buy milk",wantStatus: http.StatusCreated,},{name: "empty title returns 400",title: "",wantStatus: http.StatusBadRequest,wantError: "title is required",},{name: "too-long title returns 400",title: strings.Repeat("a", task.MaxTitleLength+1),wantStatus: http.StatusBadRequest,wantError: "title must be 200 characters or fewer",},}for _, tc := range cases {t.Run(tc.name, func(t *testing.T) {t.Parallel()srv, _ := newTestServer()rec := httptest.NewRecorder()srv.ServeHTTP(rec, jsonRequest(http.MethodPost, "/tasks", map[string]string{"title": tc.title}))if rec.Code != tc.wantStatus {t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)}if tc.wantError != "" {var got map[string]stringdecodeInto(t, rec, &got)if got["error"] != tc.wantError {t.Errorf("error = %q, want %q", got["error"], tc.wantError)}return}var got task.TaskdecodeInto(t, rec, &got)if got.Title != tc.title {t.Errorf("title = %q, want %q", got.Title, tc.title)}if got.ID == 0 {t.Error("id = 0, want a server-assigned id")}})}}
The wantError field does double duty. When it is empty, the case expects success. The test decodes a task.Task to check the title and ensure the server assigned an id. When wantError is set, the test decodes the {"error": ...} envelope instead and returns early. There is no task to check on a rejected request.
The t.Run(tc.name, ...) call turns each row into its own subtest. If you run go test -v, it prints a line per subtest, swapping spaces in the name for underscores: TestCreateTask/too-long_title_returns_400. If one case fails, you see exactly which one failed, rather than a generic failure for the whole TestCreateTask function.
Notice the two different failure calls. We use t.Fatalf on the status check to stop that specific subtest immediately. If the status code is wrong, nothing else is worth checking. We use t.Errorf on the body checks so the subtest can keep going and report every mismatch it finds in one run. Both messages follow the same order: what we got, followed by what we want. Keeping that consistency in your own tests means a reader staring at a failing build never has to guess which number is which.
The GET /tasks/{id} endpoint takes a different shape of input than the create endpoint. Some cases need a real task seeded first, while others just hit a hardcoded path. Forcing these into the create table would mean adding seed and target fields that stay empty for most rows. Instead, TestGetTask gets its own small table:
go// internal/task/handler_test.gofunc TestGetTask(t *testing.T) {t.Parallel()cases := []struct {name stringseed booltarget stringwantStatus intwantError string}{{name: "existing id returns 200",seed: true,wantStatus: http.StatusOK,},{name: "non-numeric id returns 400",target: "/tasks/abc",wantStatus: http.StatusBadRequest,wantError: "invalid task id",},{name: "missing id returns 404",target: "/tasks/999",wantStatus: http.StatusNotFound,wantError: "task not found",},}for _, tc := range cases {t.Run(tc.name, func(t *testing.T) {t.Parallel()srv, store := newTestServer()target := tc.targetif tc.seed {created, err := store.Create(context.Background(), task.Task{Title: "Buy milk"})if err != nil {t.Fatalf("seed store: %v", err)}target = fmt.Sprintf("/tasks/%d", created.ID)}rec := httptest.NewRecorder()srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))if rec.Code != tc.wantStatus {t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)}if tc.wantError != "" {var got map[string]stringdecodeInto(t, rec, &got)if got["error"] != tc.wantError {t.Errorf("error = %q, want %q", got["error"], tc.wantError)}return}var got task.TaskdecodeInto(t, rec, &got)if got.Title != "Buy milk" {t.Errorf("title = %q, want %q", got.Title, "Buy milk")}})}}
The seed field decides whether the case creates a task first and requests its real id, or jumps straight to a hardcoded target like /tasks/999.
The create cases and the id cases share almost nothing beyond the basic request-and-assert mechanics. Two small tables read much better than one bloated table stretched to cover both. This is a common mistake worth avoiding: stuffing every case you can think of into a single giant table just because the pattern exists. A table earns its keep when the cases are genuinely similar. When they are not, a second table—or a separate test entirely—is much clearer.
Not every test earns a table. TestListTasks covers exactly one scenario: seed a task, list it, and check the shape of the response.
go// internal/task/handler_test.gofunc TestListTasks(t *testing.T) {t.Parallel()srv, store := newTestServer()if _, err := store.Create(context.Background(), task.Task{Title: "Buy milk"}); err != nil {t.Fatalf("seed store: %v", err)}rec := httptest.NewRecorder()srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil))if rec.Code != http.StatusOK {t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)}var got []task.TaskdecodeInto(t, rec, &got)if len(got) != 1 {t.Fatalf("len(tasks) = %d, want 1", len(got))}if got[0].Title != "Buy milk" {t.Errorf("title = %q, want %q", got[0].Title, "Buy milk")}}
Because there is only one case, wrapping it in a cases := []struct{...}{{...}} slice would be pure ceremony. It would require more code than the straight-line version it replaces.
The rule of thumb is simple. Reach for a table when several cases vary in only a couple of fields. Reach for a plain test when there is only one case, or when the cases differ so much that a shared table would need mostly-empty fields and extra branching just to hold them.
Every test function in handler_test.go calls t.Parallel(), and so does every subtest inside the two tables. When you do this, go test pauses each one, waits for the rest of the file to register, and then runs them all concurrently. This is safe here only because newTestServer() builds a fresh fakeStore for every case. No case can see another case's data, so running them at the same time cannot cause interference.
Before Go 1.22, for _, tc := range cases reused a single tc variable across every loop iteration. A parallel subtest's closure captured that variable by reference. Because t.Parallel() defers the closure until after the loop has already finished, every subtest ended up running with whatever case happened to be last. The standard fix was to add a shadowing line at the top of the loop body, like tc := tc, giving each closure its own copy to capture.
Go 1.22 fixed this at the language level. Each iteration of a for range loop now gets its own fresh copy of the loop variable. That is why the loops in this chapter's code do not have a tc := tc line—it is no longer needed. If you come across that line in an older codebase, now you know exactly what bug it was guarding against.
bashgo test ./... -cover

The internal/task package comes back at 40.2% statement coverage. This is driven entirely by the create, list, and get paths these tests exercise. That number is not a target to chase. The cmd/server package shows 0.0% because main's wiring has no tests at all. The untested Update and Delete handlers are the main reason internal/task isn't higher.
Coverage tells you what code ran during the test suite. It does not guarantee that the code was checked correctly—a line can execute and still go unverified. Chapter 31 adds integration tests against a real database using this same table-driven style, which will push coverage further without changing how these tests work.
Section 6 begins in the next chapter. Chapter 26 runs PostgreSQL with Docker Compose, giving the API a real database to persist to.