Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · Building Production APIs in Go

Testing Go APIs with httptest

Write table-driven tests that send real requests to your router in memory, check status codes and JSON bodies, and swap the database for a fake through an interface. No running server needed.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A test suite for the books API: table-driven cases for each endpoint, a fake store injected through an interface, and helpers that keep each test case to one line.

The idea

  • httptest.NewRecorder() + router.ServeHTTP(rec, req) runs the full stack (routing, middleware, handler) in memory. This works with net/http, Chi, Gin and Echo because they're all http.Handlers; Fiber uses app.Test(req).
  • Table-driven tests list cases as data (name, request, expected status and body) and loop over them with t.Run(tc.name, …), so adding a case is one line.
  • Dependency injection through interfaces: handlers depend on a BookStore interface, so tests pass an in-memory fake instead of a database.
  • Run with go test ./...; add -race to catch data races, and -cover for coverage.

The real test file looks like this:

func TestBooksAPI(t *testing.T) {
	router := NewRouter(&fakeStore{books: map[string]Book{"1": {ID: "1", Title: "Learning Go"}}})
	cases := []struct {
		name, method, path, body string
		wantStatus               int
		wantBody                 string
	}{
		{"get existing", "GET", "/books/1", "", 200, `"title":"Learning Go"`},
		{"get missing", "GET", "/books/9", "", 404, `"error"`},
		{"create invalid", "POST", "/books", `{}`, 422, `"title"`},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			router.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
			if rec.Code != tc.wantStatus {
				t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
			}
			if !strings.Contains(rec.Body.String(), tc.wantBody) {
				t.Errorf("body %q does not contain %q", rec.Body.String(), tc.wantBody)
			}
		})
	}
}

The code

The runnable version below uses the same table and the same checks, with a tiny stand-in for t.Run so you can see the results here. One case is wrong on purpose, to show what a failure looks like.

Table-driven API tests with a fake store
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

type Book struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

type BookStore interface {
	Get(id string) (Book, bool)
	Add(b Book) Book
}

type fakeStore struct{ books map[string]Book }

func (f *fakeStore) Get(id string) (Book, bool) { b, ok := f.books[id]; return b, ok }
func (f *fakeStore) Add(b Book) Book {
	b.ID = fmt.Sprint(len(f.books) + 1)
	f.books[b.ID] = b
	return b
}

func NewRouter(store BookStore) http.Handler {
	mux := http.NewServeMux()
	reply := func(w http.ResponseWriter, status int, v any) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		json.NewEncoder(w).Encode(v)
	}
	mux.HandleFunc("GET /books/{id}", func(w http.ResponseWriter, r *http.Request) {
		if b, ok := store.Get(r.PathValue("id")); ok {
			reply(w, 200, b)
			return
		}
		reply(w, 404, map[string]string{"error": "not found"})
	})
	mux.HandleFunc("POST /books", func(w http.ResponseWriter, r *http.Request) {
		var b Book
		if json.NewDecoder(r.Body).Decode(&b) != nil || b.Title == "" {
			reply(w, 422, map[string]string{"title": "is required"})
			return
		}
		reply(w, 201, store.Add(b))
	})
	return mux
}

func main() {
	router := NewRouter(&fakeStore{books: map[string]Book{"1": {ID: "1", Title: "Learning Go"}}})
	cases := []struct {
		name, method, path, body string
		wantStatus               int
		wantBody                 string
	}{
		{"get existing", "GET", "/books/1", "", 200, `"title":"Learning Go"`},
		{"get missing", "GET", "/books/9", "", 404, `"error"`},
		{"create valid", "POST", "/books", `{"title":"Let's Go"}`, 201, `"id":"2"`},
		{"create invalid", "POST", "/books", `{}`, 422, `"title"`},
		{"wrong expectation", "GET", "/books/1", "", 200, `"title":"Go 101"`},
	}
	passed := 0
	for _, tc := range cases {
		rec := httptest.NewRecorder()
		router.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
		var problems []string
		if rec.Code != tc.wantStatus {
			problems = append(problems, fmt.Sprintf("status = %d, want %d", rec.Code, tc.wantStatus))
		}
		if !strings.Contains(rec.Body.String(), tc.wantBody) {
			problems = append(problems, fmt.Sprintf("body %s does not contain %s", strings.TrimSpace(rec.Body.String()), tc.wantBody))
		}
		if len(problems) == 0 {
			passed++
			fmt.Printf("--- PASS: %s\n", tc.name)
		} else {
			fmt.Printf("--- FAIL: %s\n    %s\n", tc.name, strings.Join(problems, "\n    "))
		}
	}
	fmt.Printf("%d/%d passed\n", passed, len(cases))
}

Output

--- PASS: get existing
--- PASS: get missing
--- PASS: create valid
--- PASS: create invalid
--- FAIL: wrong expectation
    body {"id":"1","title":"Learning Go"} does not contain "title":"Go 101"
4/5 passed

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Starting a real server on a port for every test; httptest.NewRecorder is faster and parallel-safe.
  • Testing only the happy path. Missing resources, invalid input and auth failures are where bugs hide.
  • Depending on a real database in unit tests; use an interface and a fake, and keep a few integration tests against a real database (for example in a container).
  • Comparing whole JSON strings with formatting differences; unmarshal into a struct or check key fragments.
  • Skipping -race. Handlers are concurrent; the race detector finds shared-state bugs.

Key points

  • httptest.NewRecorder() + ServeHTTP tests the full router in memory (Fiber: app.Test).
  • Table-driven tests: cases as data, one loop, t.Run per case.
  • Inject dependencies through interfaces; fake them in tests.
  • Run go test -race -cover ./... in CI.

Further reading: httptest package, Go wiki: Table-driven tests.

Practice questions

How do you test a Go HTTP handler without starting a server?

Answer

Create a request with httptest.NewRequest, a response recorder with httptest.NewRecorder, call router.ServeHTTP(rec, req), then assert on rec.Code, rec.Header() and rec.Body. It works for net/http, Chi, Gin and Echo; Fiber uses app.Test(req).

What are table-driven tests?

Answer

Test cases are written as a slice of structs (inputs and expected outputs) and a single loop runs each one, usually as a subtest with t.Run(tc.name, ...). Adding coverage means adding a row.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?