CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/go-api-testing

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

1.06x
Quality

98%

Does it follow best practices?

Impact

99%

1.06x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
go-api-testing
description:
Go API testing patterns that catch real bugs. Covers httptest server setup, table-driven tests with subtests, test helpers, middleware testing, dependency injection with interfaces, database setup/teardown, parallel tests, testify assertions, and golden files. Triggers when writing tests for any Go HTTP API, adding test coverage, or building a new API that needs tests.
keywords:
go testing, golang test, httptest, table driven tests, go api testing, net/http test, test coverage, go test setup, database testing go, testify, t.Run, t.Parallel, middleware testing, dependency injection, golden files, test helpers, httptest.NewServer, integration tests
license:
MIT

Go API Testing

Patterns that catch real bugs in Go HTTP APIs.


1. Test Server Setup with httptest

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
}

httptest.NewServer for Integration Tests

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.


2. Table-Driven Tests with Subtests

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 ./...


3. Test Helpers in _test.go Files

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
}

4. Testing Middleware

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)
            }
        })
    }
}

5. Dependency Injection with Interfaces

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)
        }
    })
}

6. Database Setup and Teardown

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.


7. Parallel Tests with t.Parallel

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.


8. Testify Assertions (Optional)

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.


9. Golden Files for Response Snapshots

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.


10. Testing Error Responses

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")
            }
        })
    }
}

Common Mistakes

MistakeFix
Not calling t.Helper() in test helpersAdd t.Helper() as the first line -- fixes failure line numbers
Using httptest.NewServer for every testUse NewRecorder for unit tests; NewServer only for integration tests
Shared mutable state between parallel testsGive each parallel subtest its own database/handler
No t.Run subtests in table-driven testsAlways use t.Run(tt.name, ...) for named, filterable subtests
Testing only happy pathsTest 400, 404, 409, 500 for every endpoint
Asserting only status codesAlso assert response body shape, required fields, excluded fields
Hardcoding test data inlineUse test helper functions to create test data
Database cleanup in defer with subtestsUse t.Cleanup() -- it runs after all subtests complete
No interface for external dependenciesDefine interfaces so you can swap in test doubles
Importing testify without using requireUse require for preconditions, assert for checks

Checklist

  • go test ./... passes from project root
  • Test helpers call t.Helper() and live in _test.go files
  • Table-driven tests use t.Run for subtests
  • Database reset before each test (:memory: SQLite or t.Cleanup)
  • Happy path test for each endpoint
  • Error path tests: 400 (validation), 404 (not found), for each endpoint
  • Response body assertions -- not just status codes
  • Sensitive fields (password, token) excluded from responses
  • Middleware tested in isolation with dummy handlers
  • Interfaces used for external dependencies with test doubles

Verifiers

  • go-tests-created -- Create API tests using httptest
  • test-go-user-api -- Write tests for a Go user management API
  • test-go-middleware-auth -- Write tests for Go middleware and auth
  • test-go-crud-with-database -- Write tests for Go API with database isolation
  • test-go-table-driven-validation -- Write table-driven validation tests
  • test-go-dependency-injection -- Write tests using interface-based dependency injection
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/go-api-testing badge