Go API testing patterns -- httptest setup, table-driven tests with subtests, test helpers, middleware testing, dependency injection with interfaces, database isolation, parallel tests, testify assertions, golden files
98
98%
Does it follow best practices?
Impact
99%
1.06xAverage score across 5 eval scenarios
Passed
No known issues
Patterns that catch real bugs in Go HTTP APIs.
Use httptest.NewRecorder for handler-level tests. Use httptest.NewServer for integration tests that need a real TCP listener.
// handlers_test.go
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Test helper -- put in a _test.go file so it is only compiled during tests.
// The t.Helper() call makes failure line numbers point to the caller, not this function.
func makeRequest(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reqBody *bytes.Buffer
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
reqBody = bytes.NewBuffer(b)
} else {
reqBody = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, reqBody)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// Parse JSON response body. Fails the test if decoding fails.
func parseJSON(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response body: %v", err)
}
return resp
}When you need to test the full HTTP stack (middleware, routing, timeouts), use httptest.NewServer:
func TestIntegration(t *testing.T) {
mux := setupRoutes()
srv := httptest.NewServer(mux)
defer srv.Close()
// Uses a real HTTP client -- tests the full network path
resp, err := http.Get(srv.URL + "/api/users")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}Use NewRecorder for fast unit tests of individual handlers. Use NewServer when you need to test middleware chains, TLS, redirects, or real HTTP client behavior.
Table-driven tests are the Go idiom for testing multiple cases without duplicating code. Always use t.Run for subtests so each case has a name and can be run individually.
func TestCreateUser(t *testing.T) {
handler := setupRoutes()
tests := []struct {
name string
body map[string]any
wantCode int
wantKey string // key expected in response body
}{
{
name: "valid user",
body: map[string]any{"name": "Alice", "email": "alice@example.com"},
wantCode: http.StatusCreated,
wantKey: "data",
},
{
name: "missing email",
body: map[string]any{"name": "Alice"},
wantCode: http.StatusBadRequest,
wantKey: "error",
},
{
name: "empty body",
body: map[string]any{},
wantCode: http.StatusBadRequest,
wantKey: "error",
},
{
name: "missing name",
body: map[string]any{"email": "alice@example.com"},
wantCode: http.StatusBadRequest,
wantKey: "error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := makeRequest(t, handler, "POST", "/api/users", tt.body)
if w.Code != tt.wantCode {
t.Errorf("expected %d, got %d", tt.wantCode, w.Code)
}
resp := parseJSON(t, w)
if _, ok := resp[tt.wantKey]; !ok {
t.Errorf("expected key %q in response", tt.wantKey)
}
})
}
}Run a single subtest: go test -run TestCreateUser/missing_email -v ./...
Put test helpers in _test.go files. Go only compiles these during go test. Always call t.Helper() so test failure messages reference the caller's line number.
// testhelpers_test.go
package main
// createTestUser is a helper that creates a user via the API and returns the ID.
// Fails the test immediately if user creation fails.
func createTestUser(t *testing.T, handler http.Handler, name, email string) string {
t.Helper()
w := makeRequest(t, handler, "POST", "/api/users", map[string]any{
"name": name,
"email": email,
})
if w.Code != http.StatusCreated {
t.Fatalf("failed to create test user: status %d, body: %s", w.Code, w.Body.String())
}
resp := parseJSON(t, w)
data := resp["data"].(map[string]any)
id, ok := data["id"]
if !ok {
t.Fatal("created user has no id field")
}
return fmt.Sprintf("%v", id)
}
// assertJSONHasKeys checks that a JSON response contains all expected keys.
func assertJSONHasKeys(t *testing.T, resp map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := resp[key]; !ok {
t.Errorf("expected key %q in response, got keys: %v", key, mapKeys(resp))
}
}
}
func mapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}Test middleware in isolation by wrapping a dummy handler. Do not test middleware only through full endpoint tests.
func TestAuthMiddleware(t *testing.T) {
// Dummy handler that the middleware wraps
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":"ok"}`))
})
protected := AuthMiddleware(inner)
tests := []struct {
name string
headers map[string]string
wantCode int
}{
{"no token", nil, http.StatusUnauthorized},
{"invalid token", map[string]string{"Authorization": "Bearer bad.token.here"}, http.StatusUnauthorized},
{"valid token", map[string]string{"Authorization": "Bearer " + validTestToken()}, http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
for k, v := range tt.headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
protected.ServeHTTP(w, req)
if w.Code != tt.wantCode {
t.Errorf("expected %d, got %d", tt.wantCode, w.Code)
}
})
}
}Use interfaces to swap real dependencies for test doubles. Define the interface, accept it in your handler/service, and provide a mock in tests.
// service.go
type UserStore interface {
CreateUser(ctx context.Context, name, email string) (*User, error)
GetUser(ctx context.Context, id string) (*User, error)
ListUsers(ctx context.Context) ([]*User, error)
}
type UserHandler struct {
store UserStore
}
func (h *UserHandler) HandleGetUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+ ServeMux
user, err := h.store.GetUser(r.Context(), id)
if err != nil {
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]any{"data": user})
}// service_test.go
type mockUserStore struct {
users map[string]*User
err error // set to simulate errors
}
func (m *mockUserStore) GetUser(_ context.Context, id string) (*User, error) {
if m.err != nil {
return nil, m.err
}
u, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("not found")
}
return u, nil
}
func (m *mockUserStore) CreateUser(_ context.Context, name, email string) (*User, error) {
if m.err != nil {
return nil, m.err
}
u := &User{ID: fmt.Sprintf("%d", len(m.users)+1), Name: name, Email: email}
m.users[u.ID] = u
return u, nil
}
func (m *mockUserStore) ListUsers(_ context.Context) ([]*User, error) {
if m.err != nil {
return nil, m.err
}
users := make([]*User, 0, len(m.users))
for _, u := range m.users {
users = append(users, u)
}
return users, nil
}
func TestGetUser(t *testing.T) {
store := &mockUserStore{
users: map[string]*User{
"1": {ID: "1", Name: "Alice", Email: "alice@example.com"},
},
}
handler := &UserHandler{store: store}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users/{id}", handler.HandleGetUser)
t.Run("existing user", func(t *testing.T) {
w := makeRequest(t, mux, "GET", "/api/users/1", nil)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
})
t.Run("nonexistent user", func(t *testing.T) {
w := makeRequest(t, mux, "GET", "/api/users/999", nil)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
})
}Reset the database before each test, not after. A test that panics mid-way still leaves a clean slate for the next test. Use :memory: SQLite for fast tests with no cleanup files.
// db_test.go
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
// Run migrations
if err := runMigrations(db); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
// Register cleanup -- runs when the test finishes
t.Cleanup(func() {
db.Close()
})
return db
}
func seedTestData(t *testing.T, db *sql.DB) {
t.Helper()
_, err := db.Exec(`INSERT INTO users (name, email) VALUES ('Seed User', 'seed@example.com')`)
if err != nil {
t.Fatalf("failed to seed test data: %v", err)
}
}
func TestUserCRUD(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
handler := NewUserHandler(db)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users", handler.List)
mux.HandleFunc("POST /api/users", handler.Create)
t.Run("list returns seeded users", func(t *testing.T) {
w := makeRequest(t, mux, "GET", "/api/users", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
resp := parseJSON(t, w)
data := resp["data"].([]any)
if len(data) == 0 {
t.Fatal("expected at least one user from seed data")
}
})
}Use t.Cleanup instead of defer when the cleanup must run after subtests complete.
Call t.Parallel() to run tests concurrently. This catches shared-state bugs and speeds up the suite. Each parallel test MUST have its own isolated state.
func TestEndpoints(t *testing.T) {
tests := []struct {
name string
method string
path string
wantCode int
}{
{"list users", "GET", "/api/users", http.StatusOK},
{"get nonexistent", "GET", "/api/users/999", http.StatusNotFound},
{"create invalid", "POST", "/api/users", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Each parallel subtest needs its own database and handler
db := setupTestDB(t)
seedTestData(t, db)
handler := setupRoutes(db)
w := makeRequest(t, handler, tt.method, tt.path, nil)
if w.Code != tt.wantCode {
t.Errorf("expected %d, got %d", tt.wantCode, w.Code)
}
})
}
}Critical: When using t.Parallel() in a for loop with Go < 1.22, capture the loop variable: tt := tt. Go 1.22+ fixes this with per-iteration scoping.
github.com/stretchr/testify provides concise assertions and better failure messages. It is the most widely used Go assertion library. Use it when tests benefit from clearer failure output.
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateUserWithTestify(t *testing.T) {
handler := setupRoutes()
w := makeRequest(t, handler, "POST", "/api/users", map[string]any{
"name": "Alice", "email": "alice@example.com",
})
// require stops the test immediately on failure (like t.Fatal)
require.Equal(t, http.StatusCreated, w.Code)
resp := parseJSON(t, w)
data := resp["data"].(map[string]any)
// assert continues execution on failure (like t.Error)
assert.Equal(t, "Alice", data["name"])
assert.Equal(t, "alice@example.com", data["email"])
assert.Contains(t, data, "id")
assert.NotContains(t, data, "password")
}Use require for preconditions (status code, JSON parse). Use assert for individual field checks so you see all failures at once.
Golden files store expected JSON responses on disk. When the API output changes, update the golden file. This catches unintended response shape changes.
import (
"os"
"path/filepath"
)
var updateGolden = flag.Bool("update-golden", false, "update golden files")
func TestListUsersGolden(t *testing.T) {
handler := setupRoutes()
w := makeRequest(t, handler, "GET", "/api/users", nil)
got := w.Body.Bytes()
golden := filepath.Join("testdata", "list_users.golden.json")
if *updateGolden {
os.MkdirAll("testdata", 0o755)
os.WriteFile(golden, got, 0o644)
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("failed to read golden file (run with -update-golden to create): %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("response does not match golden file.\ngot: %s\nwant: %s", got, want)
}
}Update golden files: go test -update-golden ./...
Put golden files in a testdata/ directory. Go tooling ignores testdata/ directories by convention.
Test error paths for every endpoint. A 500 from an unhandled error in production is the most common API bug.
func TestErrorResponses(t *testing.T) {
handler := setupRoutes()
tests := []struct {
name string
method string
path string
body any
wantCode int
}{
{"get nonexistent user", "GET", "/api/users/99999", nil, http.StatusNotFound},
{"create with empty body", "POST", "/api/users", map[string]any{}, http.StatusBadRequest},
{"create with missing fields", "POST", "/api/users", map[string]any{"name": "Alice"}, http.StatusBadRequest},
{"update nonexistent", "PUT", "/api/users/99999", map[string]any{"name": "X"}, http.StatusNotFound},
{"delete nonexistent", "DELETE", "/api/users/99999", nil, http.StatusNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := makeRequest(t, handler, tt.method, tt.path, tt.body)
if w.Code != tt.wantCode {
t.Errorf("expected %d, got %d, body: %s", tt.wantCode, w.Code, w.Body.String())
}
// Verify error response is JSON with expected shape
resp := parseJSON(t, w)
if _, ok := resp["error"]; !ok {
t.Error("expected error key in response body")
}
})
}
}| Mistake | Fix |
|---|---|
Not calling t.Helper() in test helpers | Add t.Helper() as the first line -- fixes failure line numbers |
Using httptest.NewServer for every test | Use NewRecorder for unit tests; NewServer only for integration tests |
| Shared mutable state between parallel tests | Give each parallel subtest its own database/handler |
No t.Run subtests in table-driven tests | Always use t.Run(tt.name, ...) for named, filterable subtests |
| Testing only happy paths | Test 400, 404, 409, 500 for every endpoint |
| Asserting only status codes | Also assert response body shape, required fields, excluded fields |
| Hardcoding test data inline | Use test helper functions to create test data |
Database cleanup in defer with subtests | Use t.Cleanup() -- it runs after all subtests complete |
| No interface for external dependencies | Define interfaces so you can swap in test doubles |
| Importing testify without using require | Use require for preconditions, assert for checks |
go test ./... passes from project roott.Helper() and live in _test.go filest.Run for subtests:memory: SQLite or t.Cleanup)