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/tableplay

1. 基本のテーブル駆動

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)
            }
        })
    }
}

テーブル駆動テストの構造

  1. ケースのスライス: []struct{...} でテストケースを列挙
  2. name フィールド: t.Run に渡してケース識別
  3. 入力 + 期待値: input, want, wantErr などの命名規約
  4. ループでサブテスト化: 各ケースを t.Run で実行

慣習: wantgot と対になる名前。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.DeepEqualbool を返すだけで「どこが違うか」を教えてくれない。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 casestc がループの全イテレーションで 同じアドレス を共有していた。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/mockmoq

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/quickgopter)。これはランダム入力で性質を検証する別の哲学。

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 ベースで:

  1. UserService を作る: 登録時にメール重複チェック、Reservation 期間中はブロックなどビジネスロジック
  2. fakeUserRepo を手書きで実装(map ベース)
  3. テーブル駆動テストUserService.Register をテスト
  4. httptest でハンドラ層のテスト
  5. go-cmp で構造体の比較
  6. ビルドタグ で 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 フィールドで設定可能 に。

対比表で違いを明確化

モックライブラリの選択

方式学習コスト推奨用途
手書き fakeGo の主流。シンプル
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 から状況で選択

さらに深掘りするなら


次のレッスン

3-5 認証 で認証(bcrypt、セッション、JWT)の実装に進む。

つながりの予告

  • 本章の httptest.NewServerJWT認証エンドポイントのテスト が書ける
  • fakeUserRepo の発想を fakeSessionStore に拡張
  • 認証必須エンドポイントを 認証あり/なし両方のケース でテーブル駆動