Go Practice #6 Testing and Deployment — httptest and Docker
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
#
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 requesthttptest.NewRecorder— aResponseWriterimplementation 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 #
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.
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.
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 #
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 -shortskips integration tests on fast cyclest.Cleanup— similar todeferbut 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? #
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 #
go build -o app ./cmd/server
./appA single binary — Go runtime is bundled, no separate install needed.
Static binary (cgo off) #
CGO_ENABLED=0 go build -o app ./cmd/serverWithout Advanced #5’s cgo — a static binary with no glibc dependency. Can drop into an empty image like scratch.
Embedding build info #
go build -ldflags="-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD)" ./cmd/servervar (
version = "dev"
commit = "unknown"
)In a /version endpoint or logs — always show what version is running.
Docker — multistage build #
# 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:
- distroless —
gcr.io/distroless/staticis — 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.
addr := flag.String("addr", ":8080", "listen address")
flag.Parse()For servers — environment variables are standard (Twelve-Factor App).
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 #
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)
- timeouts —
http.Server’s ReadTimeout, WriteTimeout, IdleTimeout - request size limit —
MaxBytesReader - 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+.
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:
- First HTTP server — net/http, graceful shutdown
- Routing — Go 1.22 ServeMux, chi
- JSON I/O — Encoder/Decoder, validation
- DB integration — database/sql, transactions
- Middleware — Handler adapter chains
- 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 viaNewRequest+NewRecorderNewServer— 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 Dockerfile —
scratchordistroless - 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.