In Chapter 23 we split the Task API into handler, service, and storage layers behind the TaskStore interface. That seam is what lets us test the handlers now without touching a database.
The net/http/httptest package lets you call a handler directly inside a test. It runs in the same process, with no network sockets and no running server. You build a request, hand it to the handler, and inspect exactly what comes back. You can check the status code, body, and headers, all in a single function that runs in milliseconds.

Here is what this chapter adds:
internal/task/handler_test.go, a new file with a fake in-memory store standing in for the database.httptest.NewRequest, run it through the real handler, and read the response from httptest.NewRecorder.make test target so go test ./... has a short, memorable name.httptest.ResponseRecorder. A stand-in for the real http.ResponseWriter that a browser or curl normally talks to. It records the status code, headers, and body a handler writes. This allows a test to read them back afterward instead of parsing bytes off a real network connection.
Test double / fake store. A simplified stand-in for a real dependency, used only in tests. Our fake store satisfies the TaskStore interface using a plain map instead of a database. It behaves like storage without actually needing one.
Subtest. A named test nested inside another test using t.Run(name, func(t *testing.T) {...}). Each subtest gets its own pass/fail line and its own name in the output. If a test fails, you know exactly which case broke.
A handler test exercises more than just one function. Sending a request through Routes and into a handler touches routing (does POST /tasks reach create?), decoding (does a bad JSON body return 400?), and the service's validation. It also verifies the exact status code and body the client sees. One test proves all of these pieces work together. This is much closer to what a real client experiences than a narrow test of Service.Create alone.
This approach only works cheaply because of the interface seam from Chapter 23. If handlers still held a concrete *Store, testing them would mean standing up a real database—eventually a real Postgres connection—for every single test. Because our handlers depend on the TaskStore interface, a test can hand them a fake and skip the database entirely.
This is also why these tests never touch the network. httptest.NewRequest builds an *http.Request value in memory, and calling ServeHTTP invokes your handler function directly. There is no port, no listener, and nothing to fail if some other process is already bound to port 8080.
Go's testing package requires almost no ceremony. A test is simply a function named TestXxx that takes a *testing.T argument and lives in a file ending in _test.go:
go// internal/task/handler_test.gofunc TestListTasks(t *testing.T) {// ...}
The go test command runs the tests in the current package. Running go test ./... runs every package in the module. You can add the -v flag to print each test and subtest as it runs.
Notice that handler_test.go uses package task_test rather than package task. This creates a black-box test package that can only see internal/task's exported names, like task.NewService, task.Task, and task.ErrNotFound. This is a deliberate constraint. If the tests can only reach the package through its public API, they test the exact same surface a real caller uses.
We can wire go test into the Makefile from Chapter 5 so it has a short, memorable command like make run:
makefile# Makefiletest: ## Run the testsgo test ./...
When a test has several closely related cases, t.Run groups them as named subtests instead of separate top-level functions:
go// internal/task/handler_test.gofunc TestCreateTask(t *testing.T) {t.Run("valid task returns 201 and the created task", func(t *testing.T) {// ...})t.Run("empty title returns 400 and an error envelope", func(t *testing.T) {// ...})}
Each subtest gets its own line in the -v output, such as TestCreateTask/valid_task_returns_201_and_the_created_task. A failure tells you exactly which case broke, not just which function.
The fake lives at the top of handler_test.go. It satisfies task.TaskStore using a map instead of a real database:
go// internal/task/handler_test.gotype fakeStore struct {tasks map[int]task.TasknextID int}var _ task.TaskStore = (*fakeStore)(nil)func newFakeStore() *fakeStore {return &fakeStore{tasks: make(map[int]task.Task)}}func (f *fakeStore) Create(_ context.Context, t task.Task) (task.Task, error) {f.nextID++t.ID = f.nextIDt.CreatedAt = time.Now()f.tasks[t.ID] = treturn t, nil}func (f *fakeStore) List(_ context.Context) ([]task.Task, error) {tasks := make([]task.Task, 0, len(f.tasks))for _, t := range f.tasks {tasks = append(tasks, t)}return tasks, nil}func (f *fakeStore) Get(_ context.Context, id int) (task.Task, error) {t, ok := f.tasks[id]if !ok {return task.Task{}, task.ErrNotFound}return t, nil}func (f *fakeStore) Update(_ context.Context, id int, t task.Task) (task.Task, error) {existing, ok := f.tasks[id]if !ok {return task.Task{}, task.ErrNotFound}t.ID = existing.IDt.CreatedAt = existing.CreatedAtf.tasks[id] = treturn t, nil}func (f *fakeStore) Delete(_ context.Context, id int) error {if _, ok := f.tasks[id]; !ok {return task.ErrNotFound}delete(f.tasks, id)return nil}
The var _ task.TaskStore = (*fakeStore)(nil) line is the same compile-time assertion we used in Chapter 23, now applied to the fake. If the fake ever drifts from the interface, the build fails right here instead of somewhere confusing inside a test run.
Notice that the fake can return task.ErrNotFound on demand. This lets a test drive a handler's error path (the 404 case) without needing a real database row to go missing.
Every test in this file calls newFakeStore() fresh. This matters more than it looks. A store shared across tests would let one test's data leak into another, and the order the tests run in would start to matter. That is a classic cause of a test suite that passes when run alone but fails in a full run. Giving each test its own store removes that failure mode entirely.
Three small helpers keep the tests readable. The first is newTestServer. It wires a fresh fake store through the real Service and Handler using the exact same constructors main.go uses, and then returns the mux:
go// internal/task/handler_test.gofunc newTestServer() (http.Handler, *fakeStore) {store := newFakeStore()svc := task.NewService(store)h := task.NewHandler(svc)mux := http.NewServeMux()h.Routes(mux)return mux, store}
Next, jsonRequest builds a request with a JSON body and the Content-Type header a real client would send:
go// internal/task/handler_test.gofunc jsonRequest(method, target string, body any) *http.Request {var buf bytes.Bufferif body != nil {if err := json.NewEncoder(&buf).Encode(body); err != nil {panic(err)}}req := httptest.NewRequest(method, target, &buf)req.Header.Set("Content-Type", "application/json")return req}
Finally, decodeInto reads the recorder's body back into a struct or map. This allows assertions to compare specific fields instead of raw strings:
go// internal/task/handler_test.gofunc decodeInto(t *testing.T, rec *httptest.ResponseRecorder, dst any) {t.Helper()if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {t.Fatalf("decode response body: %v", err)}}
That last helper is worth pausing on. It would be easy to write if rec.Body.String() != {"id":1,"title":"Buy milk"...}`` and call it a day. However, that breaks the moment a field is added or the JSON key order shifts, even though nothing meaningful changed. Decoding into a task.Task and checking got.Title and got.ID individually means the test only fails when the data you actually care about is wrong.
Also, t.Helper() tells go test that failures inside decodeInto should point at the line that called it, not at the line inside the helper. This is highly useful the moment you have more than one caller.
You might wonder about assertion libraries. The github.com/stretchr/testify package is the most popular one in the Go ecosystem, and its assert.Equal and require.Equal functions read a little nicer than if got != want. This chapter sticks to the standard library on purpose. Plain if statements and t.Errorf or t.Fatalf are enough to see exactly what the testing package gives you for free, before you reach for an outside dependency.
TestCreateTask is the clearest example of the full loop. We build a request, run it through the real handler, and assert on the recorder.
go// internal/task/handler_test.gofunc TestCreateTask(t *testing.T) {t.Run("valid task returns 201 and the created task", func(t *testing.T) {srv, _ := newTestServer()req := jsonRequest(http.MethodPost, "/tasks", map[string]string{"title": "Buy milk"})rec := httptest.NewRecorder()srv.ServeHTTP(rec, req)if rec.Code != http.StatusCreated {t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)}if got := rec.Header().Get("Content-Type"); got != "application/json" {t.Errorf("Content-Type = %q, want application/json", got)}var got task.TaskdecodeInto(t, rec, &got)if got.Title != "Buy milk" {t.Errorf("title = %q, want %q", got.Title, "Buy milk")}if got.ID == 0 {t.Error("id = 0, want a server-assigned id")}})t.Run("empty title returns 400 and an error envelope", func(t *testing.T) {srv, _ := newTestServer()req := jsonRequest(http.MethodPost, "/tasks", map[string]string{"title": ""})rec := httptest.NewRecorder()srv.ServeHTTP(rec, req)if rec.Code != http.StatusBadRequest {t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)}var got map[string]stringdecodeInto(t, rec, &got)if got["error"] != "title is required" {t.Errorf("error = %q, want %q", got["error"], "title is required")}})}
The srv.ServeHTTP(rec, req) line is the whole trick. The srv variable is the mux where Routes registered its handlers. This line runs the real routing, decoding, validation, and JSON encoding. It does not take a shortcut around any of it. The fake store only shows up one layer down, inside Service, completely invisible to everything this test asserts on.
TestListTasks seeds data a different way. It writes straight to the fake store instead of going through HTTP first:
go// internal/task/handler_test.gofunc TestListTasks(t *testing.T) {srv, store := newTestServer()if _, err := store.Create(context.Background(), task.Task{Title: "Buy milk"}); err != nil {t.Fatalf("seed store: %v", err)}req := httptest.NewRequest(http.MethodGet, "/tasks", nil)rec := httptest.NewRecorder()srv.ServeHTTP(rec, req)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")}}
The newTestServer function returns the store alongside the mux for exactly this reason. A test can reach past the HTTP layer to set up state directly. This is faster and clearer than issuing a POST request just to get one row in place before the real assertion.
TestGetTask has two subtests, mirroring the shape of TestCreateTask. The interesting one is the 404 case, since it needs no seeded data at all:
go// internal/task/handler_test.gofunc TestGetTask(t *testing.T) {t.Run("missing id returns 404", func(t *testing.T) {srv, _ := newTestServer()req := httptest.NewRequest(http.MethodGet, "/tasks/999", nil)rec := httptest.NewRecorder()srv.ServeHTTP(rec, req)if rec.Code != http.StatusNotFound {t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)}var got map[string]stringdecodeInto(t, rec, &got)if got["error"] != "task not found" {t.Errorf("error = %q, want %q", got["error"], "task not found")}})}
An empty fake store already returns task.ErrNotFound for any ID, so this subtest never has to create anything first. It just asks for ID 999 and checks that writeServiceError from Chapter 23 still maps that error to a 404.
Each of these three test functions repeats a similar shape: build a request, call newTestServer, run it, and assert. That repetition is deliberate. Chapter 25 collapses this exact pattern into a single table-driven test. Seeing the repetitive version first makes the refactor much easier to appreciate.
You can run the tests directly:
bashgo test ./...
Or, now that the Makefile has a target for it, you can run:
bashmake test

The cmd/server package shows up as [no test files]. This is expected since we have not written any tests for main.go. Meanwhile, internal/task runs all three test functions and every subtest, and they all pass.
Running golangci-lint run and go build ./... on this branch still comes back clean. No production files changed. The handler.go, service.go, store.go, and task.go files are byte-for-byte identical to what they were at the end of Chapter 23.
In Chapter 25, we will refactor these three repetitive test functions into a single table-driven test. This is the idiomatic Go pattern for covering many similar cases with one loop.