Go実践 #6 テストとデプロイ — httptestとDocker
#5 ミドルウェアまでで — 小さなサービスの骨格は全部見ました。最後の記事はその上に重ねる2つ。テストとデプロイ。
Goがバックエンドでよく採用されるもう1つの理由 — ビルド結果が単一の静的バイナリ。デプロイがシンプルです。
httptest — ハンドラ単位テスト
#
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())
}
}中核2つの道具:
httptest.NewRequest— 偽のリクエスト生成httptest.NewRecorder—ResponseWriter実装、レスポンスをメモリに
サーバーを起動せずに — 関数呼び出しだけでハンドラをテスト。速くて隔離。
ルーターを通すテスト #
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)
}
}ルーティング + ミドルウェア + ハンドラまで — 一度に検証。通常この単位がコスパが一番良いです。
httptest.NewServer — 本物のサーバーを立ててテスト
#
外部クライアントやライブラリを — 実際の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 — 任意のポートの実URL。外部ライブラリ(例: SDKが自前のHTTPクライアントを使う場合)の検証に向いています。
Table-drivenテスト #
中級#6の中核パターンが — ハンドラテストでも自然です。
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, ...)は — サブテストとして名前を付けて失敗位置が明確。-runフラグで — 特定のケースだけ選んで実行可能。
結合テスト — 実際の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: "イ・ドギョン"})
got := repo.Get(id)
if got.Name != "イ・ドギョン" {
t.Errorf("got %q", got.Name)
}
}中核:
testing.Short()+go test -shortで速いサイクルでは結合テストをスキップt.Cleanup—deferと似ているがサブテストでも動作- テスト用の別DB / スキーマ — 実データに触れないように
testcontainers #
代替として — testcontainers-goでテスト時点にコンテナを立ち上げる。隔離は良いが時間がかかります。
Mock vs 実物 — どちら? #
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 }ガイドライン:
- インターフェース単位でテスト — ハンドラはインターフェースだけ受けるよう設計
- mockはシンプルなほど良い — 手書きのfakeが上級#7のmockgenより読みやすい場面も多い
- ロジックがDBにあるなら — mockでは意味のあるテストにならず、実DBで
ビルド — 単一バイナリ #
go build -o app ./cmd/server
./app1つのバイナリ — Goランタイムが中に入っているので別途インストール不要。
静的バイナリ(cgoを切る) #
CGO_ENABLED=0 go build -o app ./cmd/server上級#5のcgoを使わないなら — glibcの依存もない静的バイナリ。scratchのような空のイメージにそのまま入れられます。
ビルド情報を埋め込む #
go build -ldflags="-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD)" ./cmd/servervar (
version = "dev"
commit = "unknown"
)/versionエンドポイントやログに — どのバージョンが動いているか常に表示。
Docker — マルチステージビルド #
# 1) ビルド段階
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) 実行イメージ — scratchまたは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"]中核:
- マルチステージ — ビルド環境と実行環境の分離。最終イメージからコンパイラやソースを除去
- scratch — 完全に空のイメージ。静的バイナリだけが入る(~10 MBイメージが可能)
- CA証明書 — HTTPS呼び出しのために —
/etc/ssl/certs/だけコピー
代替:
- distroless —
gcr.io/distroless/staticが — 検証された小さなベース - alpine — shellが必要なとき、ただしmusl依存がcgoと摩擦
環境変数 vs フラグ #
小さなツールは — flagパッケージでコマンドライン。
addr := flag.String("addr", ":8080", "listen address")
flag.Parse()サーバーは — 環境変数が標準(Twelve-Factor App)。
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}caarlos0/envのようなパッケージでstructタグの自動マッピングが楽です(上級#4 reflectの事例)。
ヘルスチェックと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— プロセスが生きている(kubelet liveness)/ready— 依存(DBなど)までOK(kubelet readiness)
Kubernetesのような環境で — ローリングアップデートが安全に動作するために必要。
運用チェックリスト #
小さなサービスでも運用に乗せるなら:
- graceful shutdown (#1)
- タイムアウト —
http.ServerのReadTimeout、WriteTimeout、IdleTimeout - リクエストサイズ制限 —
MaxBytesReader - panic復旧ミドルウェア (#5)
- リクエストID + 構造化ログ (slog)
- ヘルス/readinessエンドポイント
- メトリクス — Prometheus
/metrics(prometheus/client_golang) - ゴルーチンリークモニタリング — pprof公開(上級#6)
- DBプール設定の明示 (#4)
- CGOを切る — 可能なら静的バイナリ
- バージョン埋め込みビルド —
-ldflags - ヘルス/メトリクスポートは外部公開しない
構造化ログ — log/slog
#
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}JSONで — ログ収集システム(ELK、CloudWatch、Datadog)が自動パース。以前はzap、zerologのような外部に依存していましたが — 今は標準だけで十分な場面が多い。
シリーズの締めくくり #
Go実践6編を振り返ると:
- はじめてのHTTPサーバー — net/http、graceful shutdown
- ルーティング — Go 1.22 ServeMux、chi
- JSON入出力 — Encoder/Decoder、検証
- DB連携 — database/sql、トランザクション
- ミドルウェア — Handlerアダプタチェーン
- テストとデプロイ (この記事)
要約すると — Goの標準ライブラリだけでも — 小さなサービスの90%がきれいに解決します。外部ツールは — 標準の限界が見えるとき1つずつ導入すれば十分。
まとめ #
この記事で整理した内容:
httptest—NewRequest+NewRecorderでハンドラ単位テストNewServer— 本物のサーバーを立てて外部ライブラリ検証- table-driven +
t.Run— 可読性の良い複数ケース testing.Short+t.Cleanup— 結合テストの標準ツール- 手書きの小さなfakeが — mockgenより明瞭なときも多い
- 単一バイナリ +
CGO_ENABLED=0 - マルチステージDockerfile —
scratchまたはdistroless - ヘルス/readiness — Kubernetes安全デプロイに必要
log/slog標準構造化ログ- 運用チェックリストは — シリーズ最後の到達点
ここまで — Go基礎7編 → 中級7編 → 上級7編 → 実践6編(計27編)。Goの標準ツールだけで小さなバックエンドを書くまでの道のりを1つのトラックに整理しました。