
Go Web Expert
- 72 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
go-web-expert is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- go-web-expert
- AI & Agent Building
- AI-coding skill
Go Web Expert by the numbers
- 72 all-time installs (skills.sh)
- Ranked #5,635 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill go-web-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 72 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Go Web Expert System
Five non-negotiable rules for production-quality Go web applications. Every handler, every service, every line of code must satisfy all five.
Quick Reference
| Topic | Reference |
|---|---|
| Validation tags, custom validators, nested structs, error formatting | references/validation.md |
| httptest patterns, middleware testing, integration tests, fixtures | references/testing-handlers.md |
Rules of Engagement
| # | Rule | One-Liner |
|---|---|---|
| 1 | Zero Global State | All handlers are methods on a struct; no package-level var for mutable state |
| 2 | Explicit Error Handling | Every error is checked, wrapped with fmt.Errorf("doing X: %w", err) |
| 3 | Validation First | All incoming JSON validated with go-playground/validator at the boundary |
| 4 | Testability | Every handler has a _test.go using httptest with table-driven tests |
| 5 | Documentation | Every exported symbol has a Go doc comment starting with its name |
Hard gates (new HTTP handler)
Apply in order. Do not treat the next step as done until the Pass when for the current step is satisfied (objective evidence on disk or in test output—not “I checked mentally”).
1. Dependencies (Rule 1) — Pass when: the handler is a method on a struct that holds every mutable dependency (db, logger, HTTP clients, caches); any new package-level var is only in the allowlist under What Is Allowed at Package Level. Evidence: constructor wires deps; no new forbidden globals from that list.
2. Boundary (Rule 3) — Pass before calling service/domain code: Pass when: the request decodes into a tagged struct and validate.Struct (or equivalent) runs; invalid JSON and validation failures have defined HTTP status bodies (e.g. 400/422). Evidence: decode + validate.Struct appear in the handler; tests or manual run show 422/400 for bad input.
3. Errors (Rule 2) — Pass when: no _ discards on the handler path; json.NewEncoder(w).Encode errors are handled; errors passed up or logged use wrapping (%w) or mapped AppError as this skill prescribes. Evidence: review the diff for ignored errors and bare return err without context where wrapping is required.
4. Tests (Rule 4) — Pass when: a _test.go exists for the handler package and calls ServeHTTP with httptest, including at least one success case and one non-2xx case (validation, not found, or domain error). Evidence: test file path exists; go test for that package passes.
5. Documentation (Rule 5) — Pass when: every new or changed exported identifier in the change has a doc comment whose first line starts with that identifier’s name. Evidence: go doc <pkg> or the IDE/doc preview shows summaries for new exports.
---
Rule 1: Zero Global State
All handlers must be methods on a server struct. No package-level var for databases, loggers, clients, or any mutable state.
// FORBIDDEN
var db *sql.DB
var logger *slog.Logger
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := db.QueryRow(...) // global state -- untestable, unsafe
}
// REQUIRED
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
}
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := s.db.QueryRow(...) // explicit dependency
}What Is Allowed at Package Level
- Constants --
const maxPageSize = 100 - Pure functions -- functions with no side effects that depend only on their arguments
- Sentinel errors --
var ErrNotFound = errors.New("not found") - Validator instance --
var validate = validator.New()(stateless after init)
What Is Forbidden at Package Level
- Database connections (
*sql.DB,*pgxpool.Pool) - Loggers (
*slog.Logger) - HTTP clients configured with timeouts or transport
- Configuration structs read from environment
- Caches, rate limiters, or any mutable shared resource
Constructor Pattern
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{
db: db,
logger: logger,
router: http.NewServeMux(),
}
s.routes()
return s
}
func (s *Server) routes() {
s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
s.router.HandleFunc("POST /api/users", s.handleCreateUser)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}---
Rule 2: Explicit Error Handling
Never ignore errors. Every error must be wrapped with context describing what was being attempted when the error occurred.
// FORBIDDEN
result, _ := doSomething()
json.NewEncoder(w).Encode(data) // error ignored
// REQUIRED
result, err := doSomething()
if err != nil {
return fmt.Errorf("doing something for user %s: %w", userID, err)
}
if err := json.NewEncoder(w).Encode(data); err != nil {
s.logger.Error("encoding response", "err", err, "request_id", reqID)
}Error Wrapping Convention
Format: "<verb>ing <noun>: %w" -- lowercase, no period, provides call-chain context.
// Good wrapping -- each layer adds context
return fmt.Errorf("creating user: %w", err)
return fmt.Errorf("inserting user into database: %w", err)
return fmt.Errorf("hashing password for user %s: %w", email, err)
// Bad wrapping
return fmt.Errorf("error: %w", err) // no context
return fmt.Errorf("Failed to create user: %w", err) // uppercase, verbose
return err // no wrapping at allStructured Error Type for HTTP APIs
type AppError struct {
Code int `json:"-"`
Message string `json:"error"`
Detail string `json:"detail,omitempty"`
}
func (e *AppError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
// Map domain errors to HTTP errors in one place
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
writeJSON(w, appErr.Code, appErr)
return
}
slog.Error("unhandled error",
"err", err,
"path", r.URL.Path,
)
writeJSON(w, 500, map[string]string{"error": "internal server error"})
}Common Mistakes
// MISTAKE: not checking Close errors on writers
defer f.Close() // at minimum, log Close errors for writable resources
// BETTER for writable resources:
defer func() {
if err := f.Close(); err != nil {
s.logger.Error("closing file", "err", err)
}
}()
// OK for read-only resources where Close rarely fails:
defer resp.Body.Close()---
Rule 3: Validation First
Use go-playground/validator for all incoming JSON. Validate at the boundary, trust internal data.
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"omitempty,gte=0,lte=150"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &AppError{Code: 400, Message: "invalid JSON", Detail: err.Error()}
}
if err := validate.Struct(req); err != nil {
return &AppError{Code: 422, Message: "validation failed", Detail: formatValidationErrors(err)}
}
// From here, req is trusted
user, err := s.userService.Create(r.Context(), req.Name, req.Email)
if err != nil {
return fmt.Errorf("creating user: %w", err)
}
writeJSON(w, http.StatusCreated, user)
return nil
}Validation Error Formatting
func formatValidationErrors(err error) string {
var msgs []string
for _, e := range err.(validator.ValidationErrors) {
msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
}
return strings.Join(msgs, "; ")
}Validation Boundary Rule
- Validate at the edge -- HTTP handlers, message consumers, CLI input
- Trust internal data -- service layer receives already-validated types
- Never validate twice -- if the handler validated, the service does not re-validate the same fields
See references/validation.md for custom validators, nested struct validation, slice validation, and cross-field validation.
---
Rule 4: Testability
Every handler must have a corresponding _test.go file using httptest. Test through the HTTP layer, not by calling handler methods directly.
func TestServer_handleGetUser(t *testing.T) {
mockStore := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
srv := NewServer(mockStore, slog.Default())
tests := []struct {
name string
path string
wantStatus int
wantBody string
}{
{
name: "existing user",
path: "/api/users/123",
wantStatus: http.StatusOK,
wantBody: `"name":"Alice"`,
},
{
name: "not found",
path: "/api/users/999",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
}
})
}
}Key Testing Principles
- Test through HTTP -- use
httptest.NewRequestandhttptest.NewRecorder, callsrv.ServeHTTP - Interface-based mocks -- define narrow interfaces at the consumer, create mock implementations for tests
- Table-driven tests -- one
[]structwith test cases, onet.Runloop - Error paths matter -- test 400s, 404s, 422s, and 500s, not just 200s
- No global test state -- each test creates its own server with its own mocks
See references/testing-handlers.md for middleware testing, integration tests with real databases, file upload testing, and streaming response testing.
---
Rule 5: Documentation
Every exported function, type, method, and constant must have a Go doc comment following standard conventions.
// CreateUser creates a new user with the given name and email.
// It returns ErrDuplicateEmail if a user with the same email already exists.
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
// ...
}
// Server handles HTTP requests for the user API.
type Server struct {
// ...
}
// NewServer creates a Server with the given dependencies.
// The logger must not be nil.
func NewServer(store UserStore, logger *slog.Logger) *Server {
// ...
}
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")Doc Comment Conventions
- Start with the name --
// CreateUser creates...not// This function creates... - First sentence is the summary -- shown in
go doclistings and IDE tooltips - Mention important error returns -- callers need to know which errors to check
- Don't document the obvious --
// SetName sets the nameadds no value - Document why, not what -- when behavior is non-obvious, explain the reasoning
Package Documentation
// Package user provides user management for the application.
// It handles creation, retrieval, and deletion of user accounts,
// with email uniqueness enforced at the database level.
package user---
Cross-Cutting Concerns
The five rules reinforce each other. Here is how they interact.
Zero Global State Enables Testability
Because all dependencies are on the struct, tests can inject mocks:
// Production
srv := NewServer(realDB, prodLogger)
// Test
srv := NewServer(mockStore, slog.Default())If db were a global var, tests would need to mutate package state, causing race conditions in parallel tests.
Validation First Simplifies Error Handling
When handlers validate at the boundary, the service layer can assume valid input. This means service-layer errors are always unexpected (database failures, network issues), and error handling becomes simpler:
func (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
// No need to check if name is empty -- handler already validated
user := &User{Name: name, Email: email}
if err := s.store.Insert(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
return user, nil
}Documentation Makes Error Handling Discoverable
Doc comments that mention error returns tell callers what to handle:
// Delete removes a user by ID.
// It returns ErrNotFound if the user does not exist.
// It returns ErrHasActiveOrders if the user has unfinished orders.
func (s *UserService) Delete(ctx context.Context, id string) error {---
Self-Review Checklist
Before considering any handler or service complete, verify all five rules:
Zero Global State
- [ ] No package-level
varfor mutable state (db, logger, clients) - [ ] All handlers are methods on a struct
- [ ] Dependencies injected through constructor
Explicit Error Handling
- [ ] No
_ignoring returned errors - [ ] All errors wrapped with
fmt.Errorf("doing X: %w", err) - [ ]
json.NewEncoder(w).Encode(...)error checked or logged - [ ] Structured
AppErrorused for HTTP error responses
Validation First
- [ ] All request structs have
validatetags - [ ]
validate.Struct(req)called before any business logic - [ ] Validation errors return 422 with field-level detail
- [ ] Service layer does not re-validate handler-validated data
Testability
- [ ]
_test.gofile exists for every handler file - [ ] Tests use
httptest.NewRequestandhttptest.NewRecorder - [ ] Table-driven tests cover happy path and error paths
- [ ] Mocks implement narrow interfaces, not concrete types
Documentation
- [ ] Every exported function has a doc comment starting with its name
- [ ] Error return values are documented
- [ ] Package has a doc comment
When to Load References
Load validation.md when:
- Adding new request types with validation tags
- Creating custom validators
- Validating nested structs, slices, or maps
- Formatting validation errors for API responses
Load testing-handlers.md when:
- Writing handler tests for the first time in a project
- Testing middleware chains or authentication
- Setting up integration tests with a real database
- Testing file uploads or streaming responses
Testing Go HTTP Handlers
httptest Fundamentals
Every handler test follows the same three-step pattern: build a request, record the response, assert on the result.
Basic Pattern
func TestServer_handleHealth(t *testing.T) {
srv := NewServer(nil, slog.Default())
req := httptest.NewRequest("GET", "/healthz", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}httptest.NewRequest vs http.NewRequest
// httptest.NewRequest -- panics on error, never returns one.
// Use in tests where a bad URL is a programming error.
req := httptest.NewRequest("GET", "/api/users/123", nil)
// http.NewRequest -- returns an error. Use when constructing
// from dynamic test data that could be invalid.
req, err := http.NewRequest("POST", "/api/users", body)
if err != nil {
t.Fatal(err)
}httptest.NewRecorder
httptest.NewRecorder returns a *httptest.ResponseRecorder that implements http.ResponseWriter and captures everything the handler writes.
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
w.Code // status code (int)
w.Body.String() // response body as string
w.Body.Bytes() // response body as []byte
w.Header().Get("Content-Type") // response headers
w.Result() // *http.Response for more detailed inspection---
Testing with Real JSON Payloads
POST with JSON Body
func TestServer_handleCreateUser(t *testing.T) {
mockStore := &MockUserStore{
CreateFunc: func(ctx context.Context, u *User) error {
u.ID = "generated-id"
return nil
},
}
srv := NewServer(mockStore, slog.Default())
body := strings.NewReader(`{"name":"Alice","email":"alice@example.com"}`)
req := httptest.NewRequest("POST", "/api/users", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusCreated, w.Body.String())
}
var resp User
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.ID == "" {
t.Error("expected non-empty user ID")
}
if resp.Name != "Alice" {
t.Errorf("name = %q, want %q", resp.Name, "Alice")
}
}Testing Validation Errors
func TestServer_handleCreateUser_validation(t *testing.T) {
srv := NewServer(&MockUserStore{}, slog.Default())
tests := []struct {
name string
body string
wantStatus int
wantErr string
}{
{
name: "missing name",
body: `{"email":"alice@example.com"}`,
wantStatus: 422,
wantErr: "name",
},
{
name: "invalid email",
body: `{"name":"Alice","email":"not-an-email"}`,
wantStatus: 422,
wantErr: "email",
},
{
name: "malformed JSON",
body: `{bad json`,
wantStatus: 400,
wantErr: "invalid JSON",
},
{
name: "empty body",
body: ``,
wantStatus: 400,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/users", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d; body = %s", w.Code, tt.wantStatus, w.Body.String())
}
if tt.wantErr != "" && !strings.Contains(w.Body.String(), tt.wantErr) {
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantErr)
}
})
}
}Decoding JSON Responses with a Helper
func decodeJSON[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var result T
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decoding response body: %v", err)
}
return result
}
// Usage
resp := decodeJSON[User](t, w)
if resp.Name != "Alice" {
t.Errorf("name = %q, want %q", resp.Name, "Alice")
}---
Testing Middleware Chains
Testing a Single Middleware
Test middleware in isolation by wrapping a known inner handler:
func TestRequestIDMiddleware(t *testing.T) {
// Inner handler that captures the request ID from context
var gotID string
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotID = RequestIDFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
handler := RequestID(inner)
t.Run("generates ID when missing", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if gotID == "" {
t.Error("expected non-empty request ID in context")
}
if w.Header().Get("X-Request-ID") == "" {
t.Error("expected X-Request-ID response header")
}
})
t.Run("preserves existing ID", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Request-ID", "test-id-123")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if gotID != "test-id-123" {
t.Errorf("request ID = %q, want %q", gotID, "test-id-123")
}
})
}Testing the Full Middleware Stack
Test through the complete stack to verify middleware ordering and interaction:
func TestMiddlewareChain(t *testing.T) {
mockStore := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
srv := NewServer(mockStore, slog.Default())
// Apply the same middleware stack as production
handler := Chain(srv, Recovery, RequestID, Logger(slog.Default()))
req := httptest.NewRequest("GET", "/api/users/123", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Header().Get("X-Request-ID") == "" {
t.Error("middleware chain did not set X-Request-ID")
}
}Testing Recovery Middleware
func TestRecoveryMiddleware(t *testing.T) {
panicking := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("something went wrong")
})
handler := Recovery(panicking)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
// Should not panic
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
}---
Testing Authentication and Authorization
Testing Auth Middleware
func TestAuthMiddleware(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
t.Fatal("expected user in context")
}
fmt.Fprintf(w, "hello %s", user.Name)
})
tokenValidator := &MockTokenValidator{
ValidateFunc: func(token string) (*User, error) {
if token == "Bearer valid-token" {
return &User{ID: "1", Name: "Alice", Roles: []string{"admin"}}, nil
}
return nil, errors.New("invalid token")
},
}
handler := AuthMiddleware(tokenValidator)(inner)
tests := []struct {
name string
authHeader string
wantStatus int
wantBody string
}{
{
name: "valid token",
authHeader: "Bearer valid-token",
wantStatus: http.StatusOK,
wantBody: "hello Alice",
},
{
name: "invalid token",
authHeader: "Bearer bad-token",
wantStatus: http.StatusUnauthorized,
},
{
name: "missing header",
authHeader: "",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
}
})
}
}Testing Role-Based Authorization
func TestRequireRole(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := RequireRole("admin")(inner)
tests := []struct {
name string
user *User
wantStatus int
}{
{
name: "admin user",
user: &User{Roles: []string{"admin"}},
wantStatus: http.StatusOK,
},
{
name: "regular user",
user: &User{Roles: []string{"user"}},
wantStatus: http.StatusForbidden,
},
{
name: "no user in context",
user: nil,
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/admin", nil)
if tt.user != nil {
ctx := context.WithValue(req.Context(), userKey, tt.user)
req = req.WithContext(ctx)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}---
Integration Tests with Real Database
Pattern: Test Database with t.Cleanup
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("opening test database: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return db
}Transaction Rollback for Test Isolation
Each test runs in a transaction that is rolled back, leaving the database unchanged:
func setupTestTx(t *testing.T, db *sql.DB) *sql.Tx {
t.Helper()
tx, err := db.Begin()
if err != nil {
t.Fatalf("beginning transaction: %v", err)
}
t.Cleanup(func() {
tx.Rollback() // always rollback -- test data never persists
})
return tx
}Full Integration Test
func TestUserStore_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
db := setupTestDB(t)
tx := setupTestTx(t, db)
store := NewUserStore(tx)
t.Run("create and retrieve", func(t *testing.T) {
user := &User{Name: "Alice", Email: "alice@example.com"}
err := store.Create(context.Background(), user)
if err != nil {
t.Fatalf("creating user: %v", err)
}
if user.ID == "" {
t.Fatal("expected non-empty ID after create")
}
got, err := store.GetByID(context.Background(), user.ID)
if err != nil {
t.Fatalf("getting user: %v", err)
}
if got.Name != "Alice" {
t.Errorf("name = %q, want %q", got.Name, "Alice")
}
})
t.Run("duplicate email", func(t *testing.T) {
user1 := &User{Name: "Bob", Email: "bob@example.com"}
if err := store.Create(context.Background(), user1); err != nil {
t.Fatalf("creating first user: %v", err)
}
user2 := &User{Name: "Bob2", Email: "bob@example.com"}
err := store.Create(context.Background(), user2)
if !errors.Is(err, ErrDuplicateEmail) {
t.Errorf("err = %v, want ErrDuplicateEmail", err)
}
})
}HTTP Integration Test
Test the full HTTP stack against a real database:
func TestServer_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
db := setupTestDB(t)
tx := setupTestTx(t, db)
store := NewUserStore(tx)
srv := NewServer(store, slog.Default())
// Create
body := strings.NewReader(`{"name":"Alice","email":"alice@test.com"}`)
req := httptest.NewRequest("POST", "/api/users", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: status = %d, want %d; body = %s", w.Code, http.StatusCreated, w.Body.String())
}
var created User
json.NewDecoder(w.Body).Decode(&created)
// Retrieve
req = httptest.NewRequest("GET", "/api/users/"+created.ID, nil)
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get: status = %d, want %d", w.Code, http.StatusOK)
}
var fetched User
json.NewDecoder(w.Body).Decode(&fetched)
if fetched.Name != "Alice" {
t.Errorf("name = %q, want %q", fetched.Name, "Alice")
}
}---
Testing File Uploads
Multipart Form Data
func TestServer_handleUpload(t *testing.T) {
srv := NewServer(&MockFileStore{}, slog.Default())
// Build multipart body
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", "test.txt")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("hello world"))
// Add a form field alongside the file
writer.WriteField("description", "test file upload")
writer.Close()
req := httptest.NewRequest("POST", "/api/upload", &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
}
}Testing File Size Limits
func TestServer_handleUpload_tooLarge(t *testing.T) {
srv := NewServer(&MockFileStore{}, slog.Default())
// Create a file that exceeds the size limit
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, _ := writer.CreateFormFile("file", "large.bin")
part.Write(make([]byte, 11<<20)) // 11MB, exceeding a 10MB limit
writer.Close()
req := httptest.NewRequest("POST", "/api/upload", &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge)
}
}---
Testing Streaming Responses
Server-Sent Events
func TestServer_handleSSE(t *testing.T) {
events := make(chan string, 3)
events <- "event 1"
events <- "event 2"
events <- "event 3"
close(events)
srv := NewServer(&MockEventSource{Events: events}, slog.Default())
req := httptest.NewRequest("GET", "/api/events", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
contentType := w.Header().Get("Content-Type")
if contentType != "text/event-stream" {
t.Errorf("Content-Type = %q, want %q", contentType, "text/event-stream")
}
body := w.Body.String()
for _, want := range []string{"event 1", "event 2", "event 3"} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}Testing with httptest.Server for Long-Lived Connections
For testing streaming with actual HTTP connections (e.g., when httptest.NewRecorder is insufficient because the handler flushes):
func TestServer_handleSSE_live(t *testing.T) {
events := make(chan string, 3)
events <- "event 1"
events <- "event 2"
events <- "event 3"
close(events)
srv := NewServer(&MockEventSource{Events: events}, slog.Default())
ts := httptest.NewServer(srv)
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/events")
if err != nil {
t.Fatalf("GET /api/events: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading body: %v", err)
}
for _, want := range []string{"event 1", "event 2", "event 3"} {
if !strings.Contains(string(body), want) {
t.Errorf("body missing %q", want)
}
}
}---
Test Fixtures in testdata/
Go's testing toolchain ignores directories named testdata. Use it to store JSON fixtures, SQL seed files, and golden files.
Directory Structure
mypackage/
handler.go
handler_test.go
testdata/
create_user_valid.json
create_user_invalid.json
golden/
user_response.json
sql/
seed_users.sqlLoading Fixtures
func loadFixture(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", path))
if err != nil {
t.Fatalf("loading fixture %s: %v", path, err)
}
return data
}
func TestServer_handleCreateUser_fromFixture(t *testing.T) {
srv := NewServer(&MockUserStore{
CreateFunc: func(ctx context.Context, u *User) error {
u.ID = "test-id"
return nil
},
}, slog.Default())
body := loadFixture(t, "create_user_valid.json")
req := httptest.NewRequest("POST", "/api/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusCreated, w.Body.String())
}
}Golden File Testing
Compare handler output against a stored golden file. Update golden files with -update flag.
var update = flag.Bool("update", false, "update golden files")
func TestServer_handleGetUser_golden(t *testing.T) {
srv := NewServer(&MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
return &User{ID: "123", Name: "Alice", Email: "alice@example.com"}, nil
},
}, slog.Default())
req := httptest.NewRequest("GET", "/api/users/123", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
goldenPath := filepath.Join("testdata", "golden", "user_response.json")
if *update {
// Pretty-print for readable golden files
var pretty bytes.Buffer
json.Indent(&pretty, w.Body.Bytes(), "", " ")
if err := os.WriteFile(goldenPath, pretty.Bytes(), 0644); err != nil {
t.Fatalf("writing golden file: %v", err)
}
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("reading golden file: %v (run with -update to create)", err)
}
// Normalize both for comparison
var gotPretty, wantPretty bytes.Buffer
json.Indent(&gotPretty, w.Body.Bytes(), "", " ")
json.Indent(&wantPretty, want, "", " ")
if gotPretty.String() != wantPretty.String() {
t.Errorf("response does not match golden file.\ngot:\n%s\nwant:\n%s",
gotPretty.String(), wantPretty.String())
}
}---
Interface-Based Mocking Pattern
Define narrow interfaces at the consumer and create mock implementations for tests.
Define the Interface
// In the handler/server package -- not in the store package
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, u *User) error
ListUsers(ctx context.Context, limit, offset int) ([]*User, error)
}Create the Mock
type MockUserStore struct {
GetUserFunc func(ctx context.Context, id string) (*User, error)
CreateUserFunc func(ctx context.Context, u *User) error
ListUsersFunc func(ctx context.Context, limit, offset int) ([]*User, error)
}
func (m *MockUserStore) GetUser(ctx context.Context, id string) (*User, error) {
return m.GetUserFunc(ctx, id)
}
func (m *MockUserStore) CreateUser(ctx context.Context, u *User) error {
return m.CreateUserFunc(ctx, u)
}
func (m *MockUserStore) ListUsers(ctx context.Context, limit, offset int) ([]*User, error) {
return m.ListUsersFunc(ctx, limit, offset)
}Use in Tests
store := &MockUserStore{
GetUserFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
// CreateUserFunc and ListUsersFunc will panic if called --
// this is intentional. If a test triggers an unexpected call,
// you want to know.
}
srv := NewServer(store, slog.Default())---
Testing Anti-Patterns
Calling handler methods directly
// BAD: bypasses routing, middleware, and ServeHTTP
srv.handleGetUser(w, req)
// GOOD: test through the full HTTP stack
srv.ServeHTTP(w, req)Shared mutable test state
// BAD: tests interfere with each other
var testDB *sql.DB
func TestA(t *testing.T) { /* uses testDB */ }
func TestB(t *testing.T) { /* uses testDB, fails if TestA runs first */ }
// GOOD: each test creates its own dependencies
func TestA(t *testing.T) {
store := &MockUserStore{...}
srv := NewServer(store, slog.Default())
// ...
}Not testing error responses
// BAD: only tests the happy path
func TestCreateUser(t *testing.T) {
// ... only tests 201 Created
}
// GOOD: tests all outcomes
func TestCreateUser(t *testing.T) {
tests := []struct{...}{
{"valid", ..., 201, ""},
{"missing name", ..., 422, "name"},
{"duplicate email", ..., 409, "email already exists"},
{"store error", ..., 500, "internal server error"},
}
}Asserting on exact JSON strings
// BAD: brittle -- breaks if field order changes or whitespace differs
if w.Body.String() != `{"id":"123","name":"Alice"}` {
// GOOD: decode and compare structs or check individual fields
var resp User
json.NewDecoder(w.Body).Decode(&resp)
if resp.Name != "Alice" {Input Validation with go-playground/validator
Common Validation Tags
The go-playground/validator package uses struct tags to declare constraints. Here are the most frequently used tags for web APIs.
String Constraints
type CreatePostRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Slug string `json:"slug" validate:"required,alphanum"`
Body string `json:"body" validate:"required,min=10,max=50000"`
Status string `json:"status" validate:"required,oneof=draft published archived"`
Website string `json:"website" validate:"omitempty,url"`
}| Tag | Description |
|---|---|
required | Field must be present and non-zero |
omitempty | Skip validation if field is zero value |
min=N | Minimum length (string) or value (number) |
max=N | Maximum length (string) or value (number) |
len=N | Exact length |
oneof=a b c | Value must be one of the listed options (space-separated) |
alpha | Letters only |
alphanum | Letters and numbers only |
ascii | ASCII characters only |
Format Validators
type ContactRequest struct {
Email string `json:"email" validate:"required,email"`
Phone string `json:"phone" validate:"omitempty,e164"`
Website string `json:"website" validate:"omitempty,url"`
IP string `json:"ip" validate:"omitempty,ip"`
}| Tag | Description |
|---|---|
email | Valid email address |
url | Valid URL |
uri | Valid URI |
uuid | Valid UUID (any version) |
uuid4 | Valid UUID v4 |
ip | Valid IPv4 or IPv6 address |
ipv4 | Valid IPv4 address |
e164 | Valid E.164 phone number |
json | Valid JSON string |
Numeric Constraints
type PaginationRequest struct {
Page int `json:"page" validate:"required,gte=1"`
PageSize int `json:"page_size" validate:"required,gte=1,lte=100"`
}
type ProductRequest struct {
Price float64 `json:"price" validate:"required,gt=0"`
Quantity int `json:"quantity" validate:"required,gte=0,lte=10000"`
Weight float64 `json:"weight" validate:"omitempty,gte=0"`
}| Tag | Description |
|---|---|
gt=N | Greater than N |
gte=N | Greater than or equal to N |
lt=N | Less than N |
lte=N | Less than or equal to N |
ne=N | Not equal to N |
---
Custom Validators
Register custom validation functions for domain-specific rules.
Simple Custom Validator
func setupValidator() *validator.Validate {
v := validator.New()
// Register a custom "slug" validator
v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
val := fl.Field().String()
matched, _ := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, val)
return matched
})
// Register a custom "strong_password" validator
v.RegisterValidation("strong_password", func(fl validator.FieldLevel) bool {
val := fl.Field().String()
if len(val) < 8 {
return false
}
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(val)
hasLower := regexp.MustCompile(`[a-z]`).MatchString(val)
hasDigit := regexp.MustCompile(`[0-9]`).MatchString(val)
return hasUpper && hasLower && hasDigit
})
return v
}Usage:
type CreatePostRequest struct {
Slug string `json:"slug" validate:"required,slug"`
}
type RegisterRequest struct {
Password string `json:"password" validate:"required,strong_password"`
}Custom Validator with Parameters
// Usage: validate:"not_reserved=admin root system"
v.RegisterValidation("not_reserved", func(fl validator.FieldLevel) bool {
val := fl.Field().String()
param := fl.Param() // "admin root system"
reserved := strings.Fields(param)
for _, r := range reserved {
if strings.EqualFold(val, r) {
return false
}
}
return true
})Using JSON Tag Names in Error Messages
By default, validator uses Go struct field names in errors. Register the JSON tag name function to get API-friendly field names:
v := validator.New()
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})Now e.Field() returns "email" instead of "Email" in validation errors.
---
Nested Struct Validation
Validator automatically descends into nested structs when validate:"required" or validate:"dive" is used.
Required Nested Struct
type CreateOrderRequest struct {
Items []OrderItem `json:"items" validate:"required,min=1,dive"`
Address ShippingAddress `json:"address" validate:"required"`
}
type OrderItem struct {
ProductID string `json:"product_id" validate:"required,uuid"`
Quantity int `json:"quantity" validate:"required,gte=1,lte=100"`
}
type ShippingAddress struct {
Street string `json:"street" validate:"required,min=1,max=200"`
City string `json:"city" validate:"required,min=1,max=100"`
State string `json:"state" validate:"required,len=2"`
ZipCode string `json:"zip" validate:"required,numeric,len=5"`
Country string `json:"country" validate:"required,iso3166_1_alpha2"`
}Key points:
divetells the validator to validate each element inside a slice- Without
dive, only the slice itself is checked (length, required) - Nested structs with
validate:"required"are validated recursively
Optional Nested Struct
Use a pointer for optional nested structs:
type UpdateProfileRequest struct {
Name string `json:"name" validate:"omitempty,min=1,max=100"`
Address *ShippingAddress `json:"address" validate:"omitempty"`
}When Address is nil, validation is skipped. When present, all its field rules apply.
---
Slice and Map Validation
Slice Validation
type BulkCreateRequest struct {
// Validate the slice itself (1-50 items) AND each element
Users []CreateUserRequest `json:"users" validate:"required,min=1,max=50,dive"`
}The dive tag means: after validating the slice-level constraints (min=1,max=50), validate each element according to its own struct tags.
Slice of Primitives
type TagRequest struct {
Tags []string `json:"tags" validate:"required,min=1,max=10,dive,required,min=1,max=50"`
}Reading left to right: 1. required -- slice must be present 2. min=1,max=10 -- slice must have 1-10 elements 3. dive -- now validate each element 4. required,min=1,max=50 -- each string must be non-empty and max 50 chars
Map Validation
type MetadataRequest struct {
// Validate keys and values separately
Metadata map[string]string `json:"metadata" validate:"required,max=20,dive,keys,min=1,max=50,endkeys,required,max=500"`
}Reading left to right: 1. required,max=20 -- map is required, max 20 entries 2. dive -- enter the map 3. keys,min=1,max=50,endkeys -- each key must be 1-50 chars 4. required,max=500 -- each value must be non-empty and max 500 chars
---
Cross-Field Validation
Validate fields relative to each other using eqfield, nefield, gtfield, etc.
Password Confirmation
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=72"`
ConfirmPassword string `json:"confirm_password" validate:"required,eqfield=Password"`
}Date Range Validation
type DateRangeRequest struct {
StartDate time.Time `json:"start_date" validate:"required"`
EndDate time.Time `json:"end_date" validate:"required,gtfield=StartDate"`
}Cross-Field Tags
| Tag | Description |
|---|---|
eqfield=Other | Must equal the value of Other |
nefield=Other | Must not equal the value of Other |
gtfield=Other | Must be greater than Other |
gtefield=Other | Must be greater than or equal to Other |
ltfield=Other | Must be less than Other |
ltefield=Other | Must be less than or equal to Other |
Struct-Level Validation
For complex cross-field rules that cannot be expressed with tags, use struct-level validation:
v.RegisterStructValidation(func(sl validator.StructLevel) {
req := sl.Current().Interface().(CreateEventRequest)
if req.EndDate.Before(req.StartDate) {
sl.ReportError(req.EndDate, "end_date", "EndDate", "after_start", "")
}
if req.MaxAttendees > 0 && req.MinAttendees > req.MaxAttendees {
sl.ReportError(req.MinAttendees, "min_attendees", "MinAttendees", "lte_max", "")
}
}, CreateEventRequest{})---
Error Message Formatting for API Responses
Basic Formatting
func formatValidationErrors(err error) string {
var msgs []string
for _, e := range err.(validator.ValidationErrors) {
msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
}
return strings.Join(msgs, "; ")
}Structured JSON Error Response
For richer API responses, return field-level errors as a map:
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func formatValidationErrorsJSON(err error) []ValidationError {
var errs []ValidationError
for _, e := range err.(validator.ValidationErrors) {
errs = append(errs, ValidationError{
Field: e.Field(),
Message: msgForTag(e),
})
}
return errs
}
func msgForTag(e validator.FieldError) string {
switch e.Tag() {
case "required":
return "this field is required"
case "email":
return "must be a valid email address"
case "min":
return fmt.Sprintf("must be at least %s characters", e.Param())
case "max":
return fmt.Sprintf("must be at most %s characters", e.Param())
case "oneof":
return fmt.Sprintf("must be one of: %s", e.Param())
case "uuid":
return "must be a valid UUID"
case "gte":
return fmt.Sprintf("must be at least %s", e.Param())
case "lte":
return fmt.Sprintf("must be at most %s", e.Param())
case "eqfield":
return fmt.Sprintf("must match %s", e.Param())
default:
return fmt.Sprintf("failed validation: %s", e.Tag())
}
}Usage in Handler
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &AppError{Code: 400, Message: "invalid JSON"}
}
if err := validate.Struct(req); err != nil {
errs := formatValidationErrorsJSON(err)
writeJSON(w, 422, map[string]any{
"error": "validation failed",
"fields": errs,
})
return nil
}
// proceed with validated request...
return nil
}Example API response:
{
"error": "validation failed",
"fields": [
{"field": "email", "message": "must be a valid email address"},
{"field": "name", "message": "this field is required"}
]
}---
Validation Anti-Patterns
Validating in the service layer
// BAD: validation scattered across layers
func (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
if name == "" {
return nil, errors.New("name required") // should be caught at handler
}
}Validate once at the boundary. Services receive trusted data.
Using validate tags without dive on slices
// BAD: only checks slice length, not element contents
Items []OrderItem `json:"items" validate:"required,min=1"`
// GOOD: dive validates each element
Items []OrderItem `json:"items" validate:"required,min=1,dive"`Ignoring the difference between required and omitempty
// Required: field must be present and non-zero
Name string `validate:"required"` // "" is invalid
// Omitempty: skip validation if zero, validate if present
Bio string `validate:"omitempty,min=10,max=1000"` // "" is valid, "short" is invalidNot limiting request body size
// BAD: attacker can send gigabytes
json.NewDecoder(r.Body).Decode(&req)
// GOOD: limit body size
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// MaxBytesError is returned if the limit is exceeded
return &AppError{Code: 413, Message: "request body too large"}
}