3-4. テーブル駆動テスト・モック・httptest
所要時間: 45-55分 コミット内容:
~/learn/go/level3/day04/にハンドラ + repository のモックテスト
このレッスンのゴール
- テーブル駆動テストで
[]struct{name,input,want}パターンを書ける -
cmp.Diffで構造体の差分を可視化できる - 手書きフェイクで
Repositoryをモック化 -
httptest.NewRequest/NewRecorderでハンドラを単体テスト -
httptest.NewServerで外部 API 依存をモック化 - ビルドタグで統合テストを分離
なぜ学ぶか
「同じロジックを10通りの入力で検証」がテーブル駆動の真骨頂。Go 標準ライブラリのテスト(strings, strconv, net/url)はほぼ全部この形式。本章で身につけるのは「新しいケース追加が構造体を1行足すだけ」になる書き方。さらに httptest でハンドラを単体テストできれば、DB なしで API ロジックをテスト できる。
前章とのつながり
3-3_テスト基礎 の t.Run + t.Helper() + t.Cleanup() を 本格活用 する章。3-2_CRUDハンドラ の UserRepository インターフェースを 手書きフェイクでモック化 し、UserService を DB なしでテスト。
これができると何が嬉しいか
- 新しいエッジケースの追加が10秒 - 構造体1行
- 失敗時にケース名が出る -
TestX/正常系のように特定可能 - DB なしで業務ロジックをテスト - CI が秒で完了
- 外部 API 依存を
httptestで完全制御 - 安定したテスト
ストーリー導入: テーブル駆動は「データと処理の分離」
似たような if のテストを10個書くと コピペで分岐ミス、期待値変更が複数箇所に、読みづらい。テーブル駆動なら 「データ(ケース定義)」と「処理(検証ループ)」が完全分離。ケースを追加・削除・コメントアウトが容易。Go の「コードよりデータ」哲学の典型例。
大前提: テーブル駆動テストはなぜ Go の文化なのか
Go 標準ライブラリのソース(例: strings, strconv, net/url)を覗くと、ほとんどのテストが テーブル駆動 で書かれています。これは偶然ではなく、Go コミュニティの 「データを宣言的に並べて、ロジックを1箇所に集約する」 という美意識の表れ。
- 同じロジックを多数の入力で検証したい時に 書く量が最小
- 失敗時に どの入力で落ちたか が一目で分かる(
t.Runのサブテスト名) - 新しいケース追加が 構造体を1行足すだけ
このパターンに習熟することは「Go らしいテストを書ける人」になることそのもの。本レッスンはテーブル駆動の型を頭に焼き付けるのが第一目標。
セッション①: テーブル駆動の型(25-30分)
0. 録画と作業ディレクトリ
mkdir -p ~/log ~/learn/go/level3/day04
cd ~/learn/go/level3/day04
script ~/log/go_level3_day04.log
go mod init example.com/tableplay1. 基本のテーブル駆動
package validate
import "errors"
var ErrInvalidEmail = errors.New("invalid email")
func ValidateEmail(s string) error {
if s == "" {
return ErrInvalidEmail
}
at := -1
for i, c := range s {
if c == '@' {
at = i
break
}
}
if at <= 0 || at == len(s)-1 {
return ErrInvalidEmail
}
return nil
}// validate_test.go
package validate
import (
"errors"
"testing"
)
func TestValidateEmail(t *testing.T) {
cases := []struct {
name string
input string
wantErr error
}{
{"正常", "alice@example.com", nil},
{"空文字", "", ErrInvalidEmail},
{"@なし", "alice", ErrInvalidEmail},
{"@が先頭", "@example.com", ErrInvalidEmail},
{"@が末尾", "alice@", ErrInvalidEmail},
{"日本語ドメイン", "alice@例え.com", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateEmail(tc.input)
if !errors.Is(err, tc.wantErr) {
t.Errorf("ValidateEmail(%q) = %v, want %v", tc.input, err, tc.wantErr)
}
})
}
}テーブル駆動テストの構造
- ケースのスライス:
[]struct{...}でテストケースを列挙nameフィールド:t.Runに渡してケース識別- 入力 + 期待値:
input,want,wantErrなどの命名規約- ループでサブテスト化: 各ケースを
t.Runで実行慣習:
wantはgotと対になる名前。expectedでも通じるが Go 界隈はwantが多い。エラーメッセージもgot %v, want %vの順で書く。
ケース名は日本語でも英語でもいいが、混在させない
同じパッケージ内では統一する。Obsidian の検索や Grafana のアラート文字列 で困らないため。
- チーム英語ベース → 英語に統一
- 個人プロジェクト → 日本語OK(ただし
go test -runで日本語を渡すと環境によって動かない)
2. 比較的に複雑なケース: 構造体の戻り値
func TestParseUserAgent(t *testing.T) {
cases := []struct {
name string
input string
want Browser
}{
{
name: "Chrome on Mac",
input: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0",
want: Browser{Name: "Chrome", Version: "120.0", OS: "macOS"},
},
{
name: "Safari on iPhone",
input: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1",
want: Browser{Name: "Safari", Version: "", OS: "iOS"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ParseUserAgent(tc.input)
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("ParseUserAgent(%q) mismatch (-want +got):\n%s", tc.input, diff)
}
})
}
}
cmp.Diff(go-cmp) を使う理由標準の
reflect.DeepEqualはboolを返すだけで「どこが違うか」を教えてくれない。google/go-cmpを使うと:ParseUserAgent("Mozilla/...") mismatch (-want +got): validate.Browser{ Name: "Chrome", - Version: "120.0", + Version: "119.0", OS: "macOS", }のように 構造体のどのフィールドが違うか を一目で示してくれる。複雑な構造体ほど効果が大きい。
install:
go get github.com/google/go-cmp/cmp
cmpopts でフィールドを無視する
import "github.com/google/go-cmp/cmp/cmpopts" diff := cmp.Diff(want, got, cmpopts.IgnoreFields(User{}, "CreatedAt", "UpdatedAt"))タイムスタンプのように 毎回変わるフィールド を比較対象から外せる。これがないと「テスト書いたら毎秒落ちる」みたいなことに。
3. 並列化テーブル駆動
func TestValidateEmail_Parallel(t *testing.T) {
t.Parallel() // テスト自体を並列化
cases := []struct {
name string
input string
wantErr error
}{
{"正常", "a@b.com", nil},
{"空", "", ErrInvalidEmail},
}
for _, tc := range cases {
tc := tc // Go 1.22 未満で必須のキャプチャ
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // サブテストも並列化
err := ValidateEmail(tc.input)
if !errors.Is(err, tc.wantErr) {
t.Errorf("got %v, want %v", err, tc.wantErr)
}
})
}
}Go 1.22 未満の loopvar 問題
Go 1.22 より前は、
for _, tc := range casesのtcがループの全イテレーションで 同じアドレス を共有していた。t.Parallel()でクロージャを並列実行すると、全サブテストが「最後の tc」の値を参照する。対策の
tc := tcは 新しいローカル変数にコピー することで、各クロージャが独立した値を持つようにする慣用句。Go 1.22 以降: ループ変数がブロックスコープになったので
tc := tcは不要。ただし、古いコードの読み書き で必ず出会うので意味を理解しておく。
セッション②: モック・httptest・依存性注入(25-30分)
4. インターフェース駆動でモック
// service/user.go
package service
import "context"
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) (int64, error)
}
type UserService struct {
Repo UserRepository
}
func (s *UserService) Register(ctx context.Context, email, name string) (*User, error) {
if _, err := s.Repo.GetByEmail(ctx, email); err == nil {
return nil, ErrEmailTaken
}
u := &User{Email: email, Name: name}
id, err := s.Repo.Create(ctx, u)
if err != nil {
return nil, err
}
u.ID = id
return u, nil
}// service/user_test.go
type fakeUserRepo struct {
byEmail map[string]*User
byID map[int64]*User
nextID int64
}
func newFakeUserRepo() *fakeUserRepo {
return &fakeUserRepo{
byEmail: make(map[string]*User),
byID: make(map[int64]*User),
}
}
func (r *fakeUserRepo) GetByEmail(_ context.Context, email string) (*User, error) {
if u, ok := r.byEmail[email]; ok {
return u, nil
}
return nil, ErrNotFound
}
func (r *fakeUserRepo) Create(_ context.Context, u *User) (int64, error) {
r.nextID++
u.ID = r.nextID
r.byEmail[u.Email] = u
r.byID[r.nextID] = u
return r.nextID, nil
}
func TestUserService_Register(t *testing.T) {
repo := newFakeUserRepo()
svc := &UserService{Repo: repo}
t.Run("新規ユーザー", func(t *testing.T) {
u, err := svc.Register(context.Background(), "a@b.com", "Alice")
if err != nil {
t.Fatalf("Register: %v", err)
}
if u.ID == 0 {
t.Error("ID should be set")
}
})
t.Run("メール重複", func(t *testing.T) {
_, err := svc.Register(context.Background(), "a@b.com", "Bob")
if !errors.Is(err, ErrEmailTaken) {
t.Errorf("got %v, want ErrEmailTaken", err)
}
})
}「手書きフェイク」が Go の主流
他言語だと「モックライブラリで全自動生成」が主流ですが、Go では 手書きの fake/stub がよく書かれます。理由:
- インターフェースが小さいので、手書きの負担が低い
- 読みやすい: 何がモックされているか一目瞭然
- リフレクションベースのモックライブラリ依存を避けられる
「DB操作のたびに
expect().Times(1).Returns(...)を書く」のは Java/JS 文化。Go はもっと素朴に map ベースのフェイク で済ませることが多い。
モックライブラリの選択肢
ライブラリ 特徴 手書き fake シンプル、依存なし、推奨 golang/mock(gomock)コード生成。 mockgenでインターフェースから自動生成stretchr/testify/mock動的。 On("Method").Return(...)形式matryer/moqコード生成。gomock より軽量 手書きでつらくなったら gomock か moq へ Go 公式は
golang/mockを archiveしてuber-go/mockに移行を促している。新規ならuber-go/mockかmoq。
5. httptest でハンドラ単体テスト
// handler/user.go
package handler
import (
"encoding/json"
"net/http"
)
type UserHandler struct {
Svc *service.UserService
}
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
u, err := h.Svc.Register(r.Context(), req.Email, req.Name)
if errors.Is(err, service.ErrEmailTaken) {
http.Error(w, "email taken", http.StatusConflict)
return
}
if err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(u)
}// handler/user_test.go
func TestUserHandler_Register(t *testing.T) {
cases := []struct {
name string
body string
wantStatus int
wantBody string // 部分一致でいい
}{
{
name: "成功",
body: `{"email":"a@b.com","name":"Alice"}`,
wantStatus: http.StatusCreated,
wantBody: `"email":"a@b.com"`,
},
{
name: "壊れたJSON",
body: `{"email":`,
wantStatus: http.StatusBadRequest,
},
{
name: "重複メール",
body: `{"email":"taken@b.com","name":"X"}`,
wantStatus: http.StatusConflict,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
repo := newFakeUserRepo()
repo.byEmail["taken@b.com"] = &User{Email: "taken@b.com"}
svc := &service.UserService{Repo: repo}
h := &UserHandler{Svc: svc}
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.Register(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
}
if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
t.Errorf("body = %q, want contain %q", rec.Body.String(), tc.wantBody)
}
})
}
}
httptestの2つの主役
関数 役割 httptest.NewRequestフェイク *http.Requestを生成。実際のネット通信なしhttptest.NewRecorderhttp.ResponseWriterをキャプチャするレコーダーハンドラに
(rec, req)を渡して呼ぶだけで、ネットワーク経由でなくハンドラのロジックだけ をテストできる。サーバー起動も TCP も不要。ハンドラのテストでは「ステータスコード」「ヘッダ」「レスポンスボディ」を
recから取り出して検証する。
6. httptest.NewServer で外部API依存をモック
func TestFetchUser(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/42" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":42,"name":"Alice"}`))
}))
defer ts.Close()
client := &APIClient{BaseURL: ts.URL, HTTP: ts.Client()}
u, err := client.FetchUser(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
if u.Name != "Alice" {
t.Errorf("name = %q, want Alice", u.Name)
}
}
httptest.NewServerは本物のサーバーを立てるローカルにランダムポートで HTTP サーバーを起動する。
ts.URLで URL を取得、defer ts.Close()で停止。「外部 API クライアントの単体テスト」「Webhook 受信処理のテスト」で多用する。本物の HTTP 通信が走る ので、ヘッダ・ステータス・URL パスを完全に再現できる。
3-5_認証.mdでJWT認証のテストにも使う。
APIクライアントは
BaseURLを差し替え可能にするtype APIClient struct { BaseURL string HTTP *http.Client }本番では
BaseURL = "https://api.example.com"、テストではBaseURL = ts.URL。設定で URL を変更できる作り にすることで、httptest.NewServerを素直に使える。ハードコードした URL(
http.Get("https://api.example.com/..."))を書くと、テスト時に差し替えられず詰む。
7. 統合テストの分離
// internal/repository/user_integration_test.go
//go:build integration
package repository
import "testing"
func TestUserSQLRepo_Integration(t *testing.T) {
// 実際の MySQL に接続するテスト
}# 通常のテスト
go test ./...
# 統合テストも含める
go test -tags=integration ./...ビルドタグで重いテストを分離
ファイル先頭の
//go:build integrationは ビルドタグ。-tags=integrationを付けてgo testした時だけ、このファイルがコンパイルされる。典型的な分離戦略:
- 単体テスト(手書きフェイク使用): 常に実行、5秒以内
- 統合テスト(実DB・実Redis): タグ付き、CI の一段階目では実行しない
- E2Eテスト: 別レイヤー、リリース前に実行
ローカル開発はピラミッドの下 に集中、CI でだんだん厚いテストを回す。
8. パラメータの組み合わせ爆発を避ける
// 悪い例: 入れ子ループで爆発
for _, env := range envs {
for _, role := range roles {
for _, action := range actions {
// 100ケースとかになる
}
}
}
// 良い例: 関連する組み合わせだけスライスに
cases := []struct {
name string
env, role, act string
want bool
}{
{"admin can delete in prod", "prod", "admin", "delete", true},
{"viewer cannot delete", "prod", "viewer", "delete", false},
// ... 「意味のある」組み合わせだけ
}入れ子ループで全組み合わせを生成しない
全組み合わせを自動生成すると ケースが意味不明な順序で並ぶ + 境界値や特殊ケースが混ざる。
意図のあるケースを 手書きで列挙 する方が:
- 「このケースはなぜテストしているか」が伝わる
- 失敗時にケース名から問題を推測できる
- 不要なケースで時間を浪費しない
例外: プロパティベーステスト(
testing/quick、gopter)。これはランダム入力で性質を検証する別の哲学。
9. アンチパターン集
アンチパターン: 巨大1テスト
func TestEverything(t *testing.T) { // 500行のテスト関数 }失敗時にどこで落ちたか分からない。修正コストが膨大。テーブル駆動 + サブテスト に分解する。
アンチパターン: 共有可変状態
var sharedRepo = newFakeUserRepo() func TestA(t *testing.T) { sharedRepo.Create(...) // データを残す } func TestB(t *testing.T) { // TestA のデータに依存 }テスト順序が変わると壊れる。各テストで repo を新規作成。
アンチパターン: グローバル変数モック
var Now = time.Now // テストで上書き Now = func() time.Time { return fixedTime }並列テストで競合する。globalパッケージ変数をモック対象にしない。依存性注入で構造体フィールド経由 にする。
アンチパターン: テスト名の日英混在
{"成功", ..., ...}, {"failure case", ..., ...}, {"重複エラー", ..., ...},検索性が落ちる、
-runで指定しにくい。統一する。
練習課題
3-2_CRUDハンドラ.md で作った UserRepository ベースで:
UserServiceを作る: 登録時にメール重複チェック、Reservation 期間中はブロックなどビジネスロジックfakeUserRepoを手書きで実装(map ベース)- テーブル駆動テストで
UserService.Registerをテスト httptestでハンドラ層のテストgo-cmpで構造体の比較- ビルドタグ で SQLite を使う統合テストを分離
締め: git で証跡を残す
cd ~/learn/go/level3/day04
git add .
git commit -m "feat(go): テーブル駆動 + httptest でハンドラの単体テスト"アンチパターン集 - やらかし事例(再掲含む)
テーブル駆動 / モックの落とし穴
1. 巨大1テスト 500行の
TestEverything。失敗時にどこで落ちたか分からない。テーブル + サブテスト に分解。2. 入れ子ループで組み合わせ爆発
for env { for role { for action {}}}で100ケース自動生成 → 意味不明、不要ケース多発。意味のある組み合わせだけ手書き列挙。3. テスト名の日英混在 検索性・
-run指定が困難。チームで統一。4. グローバル変数モック
var Now = time.Now // テストで Now = func() time.Time { return fixedTime }並列テストで競合。依存注入で構造体フィールド に。
5. テストデータの共有可変状態
var sharedRepo = ...をテスト間で使い回す → 順序依存で壊れる。各テストで repo を新規作成。6. ハードコード URL の API クライアント
http.Get("https://api.example.com/...")だと差し替え不能。BaseURLフィールドで設定可能 に。
対比表で違いを明確化
モックライブラリの選択
方式 学習コスト 推奨 用途 手書き fake 低 ◎ Go の主流。シンプル uber-go/mock(gomock 後継)中 ◯ 大規模、自動生成 testify/mock中 △ 動的、 On().Return()形式matryer/moq中 ◯ 軽量コード生成
テスト分離の階層
レベル 速度 依存 実行頻度 単体テスト(手書きフェイク) 秒 なし 常時 統合テスト(実DB) 秒〜分 DB起動 CI E2Eテスト 分 全環境 リリース前
関連の HTTP テストツール
ツール 用途 httptest.NewRequestフェイク Request 生成 httptest.NewRecorderレスポンスキャプチャ httptest.NewServerローカル HTTP サーバー起動 httptest.NewTLSServerHTTPS 版
自己評価チェックリスト
手を動かせた
-
[]struct{name,input,want}でテーブル駆動を書いた -
t.Run(tc.name, ...)でケース名表示 -
cmp.Diff(want, got)で構造体比較 -
cmpopts.IgnoreFieldsで時刻フィールド無視 - 手書き
fakeUserRepoで UserService テスト -
httptest.NewRequest+NewRecorderでハンドラ単体テスト -
httptest.NewServerで外部 API モック -
//go:build integrationでビルドタグ分離
説明できる
- テーブル駆動が Go コミュニティで好まれる理由
-
tc := tcキャプチャ問題(Go 1.22 未満) - 手書きフェイクが Go の主流である理由
- ビルドタグの「重いテストを分離する」設計
やらかし回避
- 入れ子ループで組み合わせ爆発させない
- グローバル変数モックを避ける
- テスト間の状態共有を避ける
- API クライアントを設定可能な BaseURL で書く
詰まった時のチートシート
| やりたいこと | 書き方 |
|---|---|
| テーブル駆動 | []struct{name,input,want} + for _, tc := range + t.Run(tc.name, ...) |
| 構造体比較 | cmp.Diff(want, got) |
| フィールド無視 | cmpopts.IgnoreFields(T{}, "FieldName") |
| Request 作成 | httptest.NewRequest(method, path, body) |
| Response 受信 | rec := httptest.NewRecorder() |
| ハンドラ実行 | h.ServeHTTP(rec, req) または handlerFunc(rec, req) |
| サーバ起動(モックAPI) | httptest.NewServer(handler) + defer ts.Close() |
| ビルドタグ | ファイル先頭に //go:build integration |
「実務OK」基準
- テーブル駆動が反射的に書ける: 「同じロジックを多入力で」のコードを見たら即変換
- インターフェース駆動で書ける: ハンドラ/サービス/リポジトリの依存方向を制御
- httptest を恐れない: ハンドラの単体テストを即座に組める
- モック戦略を選べる: 手書きフェイク・gomock・moq から状況で選択
さらに深掘りするなら
- 公式: pkg.go.dev/net/http/httptest
- google/go-cmp - 構造体差分の決定版
- uber-go/mock - gomock の後継
- matryer/moq - 軽量モック生成
- Mat Ryer: 5 advanced testing techniques in Go - 必読
- 書籍: 『Learning Go』 (Jon Bodner) テスト章
- OSS:
go-chi/chiの*_test.goがハンドラテストのお手本
次のレッスン
3-5 認証 で認証(bcrypt、セッション、JWT)の実装に進む。
つながりの予告
- 本章の
httptest.NewServerで JWT認証エンドポイントのテスト が書ける fakeUserRepoの発想をfakeSessionStoreに拡張- 認証必須エンドポイントを 認証あり/なし両方のケース でテーブル駆動