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.
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 withnet/http, Chi, Gin and Echo because they're allhttp.Handlers; Fiber usesapp.Test(req).- Table-driven tests list cases as data (
name, request, expected status and body) and loop over them witht.Run(tc.name, …), so adding a case is one line. - Dependency injection through interfaces: handlers depend on a
BookStoreinterface, so tests pass an in-memory fake instead of a database. - Run with
go test ./...; add-raceto catch data races, and-coverfor 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.
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 passedRun this on your computer — see “Get Started” for setup.
Common mistakes
- Starting a real server on a port for every test;
httptest.NewRecorderis 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()+ServeHTTPtests the full router in memory (Fiber:app.Test).- Table-driven tests: cases as data, one loop,
t.Runper 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.