Go Practice #6 Testing and Deployment — httptest and Docker

6 min read

By the end of #5 Middleware — you’ve seen the skeleton of a small service. The final post layers on two more things. Testing and deployment.

Another reason Go is often chosen for backends — the build output is a single static binary. Deployment is simple.

httptest — handler-level tests #

testing a handler directly
import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHello(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/hello", nil)
	w := httptest.NewRecorder()

	helloHandler(w, req)

	if w.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", w.Code)
	}
	if w.Body.String() != "hello\n" {
		t.Errorf("unexpected body: %q", w.Body.String())
	}
}

Two key tools:

  • httptest.NewRequest — make a fake request
  • httptest.NewRecorder — a ResponseWriter implementation that captures the response in memory

Without spinning up a server — test the handler with just a function call. Fast and isolated.

Tests that go through the router #

test through the router
func TestGetUser(t *testing.T) {
	mux := setupRoutes()

	req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
	w := httptest.NewRecorder()

	mux.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Errorf("got %d", w.Code)
	}
}

Routing + middleware + handler — all verified together. This usually gives the best ROI.

httptest.NewServer — spin up a real server to test #

Use this when you need to validate an external client or library over actual HTTP.

real server instance
func TestE2E(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(handler))
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/hello")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	// ...
}

srv.URL — a real URL on a random port. Fits validating external libraries (e.g., SDKs that use their own HTTP clients).

Table-driven tests #

Intermediate #6’s key pattern — also natural in handler tests.

many cases in one function
func TestValidateEmail(t *testing.T) {
	cases := []struct {
		name    string
		input   string
		wantErr bool
	}{
		{"empty", "", true},
		{"no @", "abc", true},
		{"valid", "a@b.c", false},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := ValidateEmail(tc.input)
			if (err != nil) != tc.wantErr {
				t.Errorf("got err=%v, wantErr=%v", err, tc.wantErr)
			}
		})
	}
}

t.Run(name, ...) — names subtests so failure locations are clear. With the -run flag — run just a specific case.

Integration tests — connect to a real DB #

setup/teardown
func TestUserRepo(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping integration test")
	}

	db := mustOpenTestDB(t)
	t.Cleanup(func() { db.Close() })

	repo := NewUserRepo(db)

	id := repo.Create(User{Name: "Dokyung Lee"})
	got := repo.Get(id)
	if got.Name != "Dokyung Lee" {
		t.Errorf("got %q", got.Name)
	}
}

Key points:

  • testing.Short() + go test -short skips integration tests on fast cycles
  • t.Cleanup — similar to defer but works in subtests too
  • A separate test DB / schema — to avoid touching real data

testcontainers #

An alternative — testcontainers-go spins containers up at test time. Better isolation, but more time.

Mock vs real — which? #

mock interface
type UserStore interface {
	Get(id int) (User, error)
	Save(u User) error
}

type fakeStore struct{ users map[int]User }

func (f *fakeStore) Get(id int) (User, error) { return f.users[id], nil }
func (f *fakeStore) Save(u User) error         { f.users[u.ID] = u; return nil }

Guidelines:

  • Test against interfaces — design handlers to take only interfaces
  • Keep mocks simple — a hand-written fake is often easier to read than Advanced #7’s mockgen
  • If logic lives in the DB — mocks won’t yield meaningful tests; use a real DB

Build — single binary #

build
go build -o app ./cmd/server
./app

A single binary — Go runtime is bundled, no separate install needed.

Static binary (cgo off) #

CGO_ENABLED=0 go build -o app ./cmd/server

Without Advanced #5’s cgo — a static binary with no glibc dependency. Can drop into an empty image like scratch.

Embedding build info #

version info
go build -ldflags="-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD)" ./cmd/server
main.go
var (
	version = "dev"
	commit  = "unknown"
)

In a /version endpoint or logs — always show what version is running.

Docker — multistage build #

Dockerfile
# 1) build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o app ./cmd/server

# 2) runtime image — scratch or distroless
FROM scratch
COPY --from=builder /app/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/app"]

Key points:

  • Multistage — separates build from runtime. Final image excludes compilers and source
  • scratch — completely empty image. Only the static binary goes in (~10 MB image possible)
  • CA certificates — copy /etc/ssl/certs/ only, for HTTPS calls

Alternatives:

  • distrolessgcr.io/distroless/static is — a vetted small base
  • alpine — when you need a shell, but musl friction with cgo

Environment variables vs flags #

For small tools — flag package on the command line.

flag
addr := flag.String("addr", ":8080", "listen address")
flag.Parse()

For servers — environment variables are standard (Twelve-Factor App).

environment variables
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
	addr = ":8080"
}

Packages like caarlos0/env can auto-map struct tags (Advanced #4 reflect case).

Health checks and readiness #

health endpoints
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
})

mux.HandleFunc("GET /ready", func(w http.ResponseWriter, r *http.Request) {
	if err := db.Ping(); err != nil {
		http.Error(w, "not ready", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})
  • /health — process is alive (kubelet liveness)
  • /ready — dependencies (DB, etc.) are also OK (kubelet readiness)

Required in environments like Kubernetes — for safe rolling updates.

Operations checklist #

Even for a small service in production:

  • graceful shutdown (#1)
  • timeoutshttp.Server’s ReadTimeout, WriteTimeout, IdleTimeout
  • request size limitMaxBytesReader
  • panic recovery middleware (#5)
  • request ID + structured logs (slog)
  • health/readiness endpoints
  • metrics — Prometheus /metrics (prometheus/client_golang)
  • goroutine-leak monitoring — expose pprof (Advanced #6)
  • explicit DB pool config (#4)
  • disable CGO — static binary when possible
  • build with embedded version-ldflags
  • don’t expose health/metrics ports externally

Structured logs — log/slog #

The structured logging library added to the standard library in Go 1.21+.

slog
import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user signed in",
	slog.String("user_id", "u1"),
	slog.Int("count", 3),
)
// {"time":"...","level":"INFO","msg":"user signed in","user_id":"u1","count":3}

In JSON — log-collection systems (ELK, CloudWatch, Datadog) auto-parse. Previously you’d reach for zap or zerolog — but in many cases the standard library now suffices.

Series wrap-up #

Looking back across the 6 Go Practice posts:

  1. First HTTP server — net/http, graceful shutdown
  2. Routing — Go 1.22 ServeMux, chi
  3. JSON I/O — Encoder/Decoder, validation
  4. DB integration — database/sql, transactions
  5. Middleware — Handler adapter chains
  6. Testing and deployment (this post)

To summarize: Go’s standard library alone solves 90% of small services cleanly. Bring in external tools one at a time when you hit standard-library limits.

Wrap-up #

What we covered:

  • httptest — handler-level tests via NewRequest + NewRecorder
  • NewServer — spin up a real server to validate external libraries
  • Table-driven + t.Run — readable multi-case tests
  • testing.Short + t.Cleanup — standard tools for integration tests
  • A small hand-written fake is often clearer than mockgen
  • Single binary + CGO_ENABLED=0
  • Multistage Dockerfilescratch or distroless
  • Health/readiness — needed for safe Kubernetes deploys
  • Standard structured logs via log/slog
  • The ops checklist — the series’ final settling point

That’s it — Go Basics 7 → Intermediate 7 → Advanced 7 → Practice 6 (27 posts total). One track from Go’s standard tools to a small backend.

X