Go実践 #6 テストとデプロイ — httptestとDocker

読了 6分

#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.NewRecorderResponseWriter実装、レスポンスをメモリに

サーバーを起動せずに — 関数呼び出しだけでハンドラをテスト。速くて隔離。

ルーターを通すテスト #

ルーターまでテスト
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の中核パターンが — ハンドラテストでも自然です。

複数のケースを1つの関数に
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接続 #

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: "イ・ドギョン"})
	got := repo.Get(id)
	if got.Name != "イ・ドギョン" {
		t.Errorf("got %q", got.Name)
	}
}

中核:

  • testing.Short() + go test -shortで速いサイクルでは結合テストをスキップ
  • t.Cleanupdeferと似ているがサブテストでも動作
  • テスト用の別DB / スキーマ — 実データに触れないように

testcontainers #

代替として — testcontainers-goでテスト時点にコンテナを立ち上げる。隔離は良いが時間がかかります。

Mock vs 実物 — どちら? #

mockインターフェース
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
./app

1つのバイナリ — 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/server
main.go
var (
	version = "dev"
	commit  = "unknown"
)

/versionエンドポイントやログに — どのバージョンが動いているか常に表示。

Docker — マルチステージビルド #

Dockerfile
# 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/だけコピー

代替:

  • distrolessgcr.io/distroless/staticが — 検証された小さなベース
  • alpine — shellが必要なとき、ただしmusl依存がcgoと摩擦

環境変数 vs フラグ #

小さなツールは — flagパッケージでコマンドライン。

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 #

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 — プロセスが生きている(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+標準に入った — 構造化ログライブラリ。

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}

JSONで — ログ収集システム(ELK、CloudWatch、Datadog)が自動パース。以前はzapzerologのような外部に依存していましたが — 今は標準だけで十分な場面が多い。

シリーズの締めくくり #

Go実践6編を振り返ると:

  1. はじめてのHTTPサーバー — net/http、graceful shutdown
  2. ルーティング — Go 1.22 ServeMux、chi
  3. JSON入出力 — Encoder/Decoder、検証
  4. DB連携 — database/sql、トランザクション
  5. ミドルウェア — Handlerアダプタチェーン
  6. テストとデプロイ (この記事)

要約すると — Goの標準ライブラリだけでも — 小さなサービスの90%がきれいに解決します。外部ツールは — 標準の限界が見えるとき1つずつ導入すれば十分。

まとめ #

この記事で整理した内容:

  • httptestNewRequest + NewRecorderでハンドラ単位テスト
  • NewServer — 本物のサーバーを立てて外部ライブラリ検証
  • table-driven + t.Run — 可読性の良い複数ケース
  • testing.Short + t.Cleanup — 結合テストの標準ツール
  • 手書きの小さなfakeが — mockgenより明瞭なときも多い
  • 単一バイナリ + CGO_ENABLED=0
  • マルチステージDockerfilescratchまたはdistroless
  • ヘルス/readiness — Kubernetes安全デプロイに必要
  • log/slog標準構造化ログ
  • 運用チェックリストは — シリーズ最後の到達点

ここまで — Go基礎7編 → 中級7編 → 上級7編 → 実践6編(計27編)。Goの標準ツールだけで小さなバックエンドを書くまでの道のりを1つのトラックに整理しました。

X