Skip to content
Published on

Goテスト完全ガイド:ユニットテスト、Mock、Stubの完全解説

Authors

はじめに

ソフトウェアの品質はテストによって証明されます。Goは言語自体に強力なテストツールを内蔵しており、testingパッケージとgo testコマンドだけでプロダクションレベルのテストを書けます。

このガイドでは、基本的なユニットテストから、Mock/Stubを活用した依存性の分離、統合テストまで、すぐに実践で使えるパターンを解説します。


1. Goテストの基礎

1.1 testingパッケージ

Goのテストは_test.goファイルに記述し、テスト関数はTestプレフィックスで始まります。

// calculator.go
package calculator

import "fmt"

func Add(a, b int) int    { return a + b }
func Sub(a, b int) int    { return a - b }
func Mul(a, b int) int    { return a * b }
func Div(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}
// calculator_test.go
package calculator

import (
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

// t.Fatal: 以降のテストコードの実行を即座に停止
func TestDiv(t *testing.T) {
    result, err := Div(10, 2)
    if err != nil {
        t.Fatalf("予期しないエラー: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Div(10, 2) = %f; want 5.0", result)
    }

    // ゼロ除算エラーのテスト
    _, err = Div(10, 0)
    if err == nil {
        t.Error("ゼロ除算ではエラーを返すべきです")
    }
}

// t.Helper(): ヘルパー関数内でのエラー位置をヘルパー内部ではなく呼び出し元に表示
func assertEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestMul(t *testing.T) {
    assertEqual(t, Mul(3, 4), 12)
    assertEqual(t, Mul(0, 100), 0)
    assertEqual(t, Mul(-2, 5), -10)
}

1.2 テストの実行

# 現在のパッケージをテスト
go test

# すべてのパッケージをテスト
go test ./...

# 詳細出力
go test -v

# 特定のテスト関数のみ実行 (正規表現)
go test -run TestAdd
go test -run TestDiv

# テストカバレッジ
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out    # ブラウザで確認
go tool cover -func=coverage.out    # 関数別カバレッジ

# タイムアウト設定
go test -timeout 30s

# レースコンディション検出
go test -race

# キャッシュを無視
go test -count=1 ./...

1.3 TestMain

package mypackage_test

import (
    "fmt"
    "os"
    "testing"
)

var testDB *Database

// TestMain: テストスイート全体のセットアップとティアダウン
func TestMain(m *testing.M) {
    // セットアップ
    fmt.Println("テスト環境のセットアップ開始")
    testDB = setupTestDatabase()

    // テスト実行
    code := m.Run()

    // ティアダウン
    testDB.Close()
    fmt.Println("テスト環境のクリーンアップ完了")

    os.Exit(code)
}

2. テーブル駆動テスト (Table-Driven Tests)

Goで最もイディオマティックなテストパターンです。

package calculator_test

import (
    "fmt"
    "testing"

    "github.com/example/calculator"
)

func TestAdd_TableDriven(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"正の数同士", 2, 3, 5},
        {"正と負", 5, -3, 2},
        {"負の数同士", -4, -6, -10},
        {"ゼロケース", 0, 0, 0},
        {"大きな数", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := calculator.Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

// エラーケースを含むテーブルテスト
func TestDiv_TableDriven(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"通常の除算", 10, 2, 5.0, false},
        {"小数の結果", 7, 2, 3.5, false},
        {"ゼロ除算", 10, 0, 0, true},
        {"負の数", -10, 2, -5.0, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := calculator.Div(tt.a, tt.b)
            if tt.expectErr {
                if err == nil {
                    t.Error("エラーを期待しましたが、nilが返されました")
                }
                return
            }
            if err != nil {
                t.Fatalf("予期しないエラー: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Div(%.1f, %.1f) = %.1f; want %.1f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

// サブテストごとのセットアップとティアダウン
func TestUserService_TableDriven(t *testing.T) {
    tests := []struct {
        name    string
        userID  int
        wantErr bool
    }{
        {"有効なユーザー", 1, false},
        {"存在しないユーザー", 999, true},
        {"負のID", -1, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // サブテストごとのセットアップ
            svc := NewTestUserService()
            defer svc.Cleanup()

            user, err := svc.GetUser(tt.userID)
            if (err != nil) != tt.wantErr {
                t.Errorf("GetUser(%d) error = %v, wantErr %v",
                    tt.userID, err, tt.wantErr)
                return
            }
            if !tt.wantErr {
                fmt.Printf("ユーザー: %+v\n", user)
            }
        })
    }
}

3. testifyライブラリ

testifyはGoで最も広く使われているテストライブラリです。

go get github.com/stretchr/testify

3.1 assertとrequireの違い

package mypackage_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWithTestify(t *testing.T) {
    result := Add(2, 3)

    // assert: 失敗してもテストを継続
    assert.Equal(t, 5, result, "2+3は5であるべきです")
    assert.NotEqual(t, 6, result)
    assert.True(t, result > 0)
    assert.False(t, result < 0)

    // require: 失敗したらテストを即座に停止
    require.NotNil(t, result)
    require.Equal(t, 5, result)
}

func TestCollections(t *testing.T) {
    nums := []int{1, 2, 3, 4, 5}

    assert.Contains(t, nums, 3)
    assert.NotContains(t, nums, 10)
    assert.Len(t, nums, 5)
    assert.Empty(t, []int{})
    assert.NotEmpty(t, nums)

    m := map[string]int{"a": 1, "b": 2}
    assert.Equal(t, 1, m["a"])
}

func TestErrors(t *testing.T) {
    _, err := Div(10, 0)
    assert.Error(t, err)
    assert.EqualError(t, err, "division by zero")

    result, err := Div(10, 2)
    assert.NoError(t, err)
    assert.Equal(t, 5.0, result)
}

func TestPanic(t *testing.T) {
    assert.Panics(t, func() {
        panic("test panic")
    })
    assert.NotPanics(t, func() {
        _ = Add(1, 2)
    })
}

3.2 assert.JSONEqとHTTPアサーション

package mypackage_test

import (
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestJSONResponse(t *testing.T) {
    handler := func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"name":"Alice","age":30}`))
    }

    req := httptest.NewRequest(http.MethodGet, "/user", nil)
    rec := httptest.NewRecorder()
    handler(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    // キーの順序に関係なくJSON内容を比較
    expected := `{"age": 30, "name": "Alice"}`
    assert.JSONEq(t, expected, rec.Body.String())
}

3.3 testify/suite

package mypackage_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/suite"
)

type UserServiceSuite struct {
    suite.Suite
    service *UserService
    db      *TestDB
}

// スイート開始前に1回実行
func (s *UserServiceSuite) SetupSuite() {
    s.db = NewTestDB()
}

// 各テスト前に実行
func (s *UserServiceSuite) SetupTest() {
    s.service = NewUserService(s.db)
    s.db.Seed()
}

// 各テスト後に実行
func (s *UserServiceSuite) TearDownTest() {
    s.db.Clean()
}

// スイート終了後に1回実行
func (s *UserServiceSuite) TearDownSuite() {
    s.db.Close()
}

func (s *UserServiceSuite) TestGetUser() {
    user, err := s.service.GetUser(1)
    s.NoError(err)
    s.Equal("Alice", user.Name)
}

func (s *UserServiceSuite) TestCreateUser() {
    user, err := s.service.CreateUser("Bob", "bob@example.com")
    s.NoError(err)
    s.NotZero(user.ID)
    s.Equal("Bob", user.Name)
}

func (s *UserServiceSuite) TestDeleteUser_NotFound() {
    err := s.service.DeleteUser(9999)
    s.Error(err)
    assert.Contains(s.T(), err.Error(), "not found")
}

// GoテストランナーにスイートをRegister
func TestUserServiceSuite(t *testing.T) {
    suite.Run(t, new(UserServiceSuite))
}

4. インターフェースを使ったMock

4.1 インターフェースベースの依存性注入

// user_repository.go
package user

import (
    "context"
    "fmt"
)

type User struct {
    ID    int
    Name  string
    Email string
}

// インターフェース定義 (依存性注入の核心)
type Repository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context) ([]*User, error)
}

type EmailSender interface {
    Send(to, subject, body string) error
}

// サービスはインターフェースに依存 (具体的な実装には依存しない)
type Service struct {
    repo  Repository
    email EmailSender
}

func NewService(repo Repository, email EmailSender) *Service {
    return &Service{repo: repo, email: email}
}

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
    return s.repo.FindByID(ctx, id)
}

func (s *Service) CreateUser(ctx context.Context, name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(ctx, user); err != nil {
        return nil, fmt.Errorf("ユーザーの保存: %w", err)
    }
    // ウェルカムメール送信 (失敗してもユーザー作成は成功)
    _ = s.email.Send(email, "ようこそ!", "ご登録ありがとうございます!")
    return user, nil
}

4.2 手動Mock実装

// テストファイルで直接定義可能
package user_test

import (
    "context"
    "fmt"
    "github.com/example/user"
)

type MockRepository struct {
    users   map[int]*user.User
    saveErr error
    findErr error
}

func NewMockRepository() *MockRepository {
    return &MockRepository{users: make(map[int]*user.User)}
}

func (m *MockRepository) FindByID(ctx context.Context, id int) (*user.User, error) {
    if m.findErr != nil {
        return nil, m.findErr
    }
    u, ok := m.users[id]
    if !ok {
        return nil, fmt.Errorf("user %d not found", id)
    }
    return u, nil
}

func (m *MockRepository) Save(ctx context.Context, u *user.User) error {
    if m.saveErr != nil {
        return m.saveErr
    }
    if u.ID == 0 {
        u.ID = len(m.users) + 1
    }
    m.users[u.ID] = u
    return nil
}

func (m *MockRepository) Delete(ctx context.Context, id int) error {
    delete(m.users, id)
    return nil
}

func (m *MockRepository) List(ctx context.Context) ([]*user.User, error) {
    users := make([]*user.User, 0, len(m.users))
    for _, u := range m.users {
        users = append(users, u)
    }
    return users, nil
}

type MockEmailSender struct {
    SentEmails []struct{ To, Subject, Body string }
    SendErr    error
}

func (m *MockEmailSender) Send(to, subject, body string) error {
    if m.SendErr != nil {
        return m.SendErr
    }
    m.SentEmails = append(m.SentEmails, struct{ To, Subject, Body string }{to, subject, body})
    return nil
}

5. gomockの使い方

5.1 gomockのインストールとコード生成

go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/gomock

インターフェースファイルにgo:generateディレクティブを追加:

// repository.go
package user

//go:generate mockgen -source=repository.go -destination=mocks/mock_repository.go -package=mocks
type Repository interface {
    FindByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context) ([]*User, error)
}
# Mockを生成
go generate ./...

5.2 gomockを使ったテスト

package user_test

import (
    "context"
    "fmt"
    "testing"

    "github.com/example/user"
    "github.com/example/user/mocks"
    "go.uber.org/mock/gomock"
    "github.com/stretchr/testify/assert"
)

func TestGetUser_WithGomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    mockEmail := mocks.NewMockEmailSender(ctrl)

    expectedUser := &user.User{ID: 1, Name: "Alice", Email: "alice@example.com"}

    // EXPECT: メソッド呼び出しの期待設定
    mockRepo.EXPECT().
        FindByID(gomock.Any(), 1).
        Return(expectedUser, nil).
        Times(1)

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expectedUser, got)
}

func TestCreateUser_WithGomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    mockEmail := mocks.NewMockEmailSender(ctrl)

    // Saveはちょうど1回呼ばれなければならない
    mockRepo.EXPECT().
        Save(gomock.Any(), gomock.Any()).
        DoAndReturn(func(ctx context.Context, u *user.User) error {
            u.ID = 42 // IDの割り当てをシミュレート
            return nil
        }).
        Times(1)

    // Sendは0回以上呼ばれてよい
    mockEmail.EXPECT().
        Send(gomock.Any(), gomock.Any(), gomock.Any()).
        Return(nil).
        AnyTimes()

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")

    assert.NoError(t, err)
    assert.Equal(t, 42, got.ID)
    assert.Equal(t, "Bob", got.Name)
}

// 特定の引数マッチャーを使用
func TestFindByID_SpecificArgs(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)

    // 正確な引数マッチング
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Eq(5)).
        Return(nil, fmt.Errorf("not found")).
        Times(1)

    // カスタムマッチャー
    mockRepo.EXPECT().
        FindByID(gomock.Any(), gomock.Not(gomock.Eq(0))).
        Return(&user.User{ID: 10}, nil).
        AnyTimes()
}

6. testify/mockの使い方

6.1 testify/mockを使ったMock実装

package user_test

import (
    "context"
    "testing"

    "github.com/example/user"
    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/assert"
)

// mock.Mockを埋め込んだMock構造体
type MockUserRepo struct {
    mock.Mock
}

func (m *MockUserRepo) FindByID(ctx context.Context, id int) (*user.User, error) {
    args := m.Called(ctx, id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*user.User), args.Error(1)
}

func (m *MockUserRepo) Save(ctx context.Context, u *user.User) error {
    args := m.Called(ctx, u)
    return args.Error(0)
}

func (m *MockUserRepo) Delete(ctx context.Context, id int) error {
    args := m.Called(ctx, id)
    return args.Error(0)
}

func (m *MockUserRepo) List(ctx context.Context) ([]*user.User, error) {
    args := m.Called(ctx)
    return args.Get(0).([]*user.User), args.Error(1)
}

func TestGetUser_Testify(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockEmail := new(MockEmailSender)

    expectedUser := &user.User{ID: 1, Name: "Alice"}

    // On()でメソッドの動作を設定
    mockRepo.On("FindByID", mock.Anything, 1).
        Return(expectedUser, nil)

    svc := user.NewService(mockRepo, mockEmail)
    got, err := svc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expectedUser, got)

    // すべての期待が満たされたか確認
    mockRepo.AssertExpectations(t)
}

func TestCreateUser_Testify(t *testing.T) {
    mockRepo := new(MockUserRepo)
    mockEmail := new(MockEmailSender)

    // mock.MatchedByでカスタムマッチャーを使用
    mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(u *user.User) bool {
        return u.Name == "Bob"
    })).Return(nil)

    mockEmail.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil)

    svc := user.NewService(mockRepo, mockEmail)
    _, err := svc.CreateUser(context.Background(), "Bob", "bob@example.com")

    assert.NoError(t, err)
    mockRepo.AssertNumberOfCalls(t, "Save", 1)
    mockEmail.AssertNumberOfCalls(t, "Send", 1)
}

7. HTTPモックサーバー (httptest)

7.1 httptest.NewRecorder

package handler_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestUserHandler_GetAll(t *testing.T) {
    store := NewInMemoryStore()
    store.Add(&User{ID: 1, Name: "Alice"})
    store.Add(&User{ID: 2, Name: "Bob"})

    handler := NewUserHandler(store)

    req := httptest.NewRequest(http.MethodGet, "/users", nil)
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    var users []User
    err := json.NewDecoder(rec.Body).Decode(&users)
    assert.NoError(t, err)
    assert.Len(t, users, 2)
}

func TestUserHandler_Create(t *testing.T) {
    store := NewInMemoryStore()
    handler := NewUserHandler(store)

    body := map[string]string{
        "name":  "Charlie",
        "email": "charlie@example.com",
    }
    jsonBody, _ := json.Marshal(body)

    req := httptest.NewRequest(http.MethodPost, "/users",
        bytes.NewBuffer(jsonBody))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusCreated, rec.Code)

    var created User
    json.NewDecoder(rec.Body).Decode(&created)
    assert.Equal(t, "Charlie", created.Name)
    assert.NotZero(t, created.ID)
}

7.2 httptest.NewServer (外部APIのモック)

package client_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestHTTPClient_GetUser(t *testing.T) {
    // 外部サービスをモックするテストサーバー
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // リクエストの検証
        assert.Equal(t, "/api/users/1", r.URL.Path)
        assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "id":    1,
            "name":  "Alice",
            "email": "alice@example.com",
        })
    }))
    defer ts.Close()

    client := NewUserClient(ts.URL, "test-token")

    user, err := client.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}

func TestHTTPClient_ServerError(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
    }))
    defer ts.Close()

    client := NewUserClient(ts.URL, "test-token")
    _, err := client.GetUser(1)
    assert.Error(t, err)
}

func TestHTTPClient_MultipleEndpoints(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode([]User{{ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}})
    })
    mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    ts := httptest.NewServer(mux)
    defer ts.Close()

    client := NewUserClient(ts.URL, "token")

    users, err := client.ListUsers()
    assert.NoError(t, err)
    assert.Len(t, users, 2)
}

8. Stubパターン

8.1 インターフェースベースのStub

// 時刻Stub: time.Now()を置き換え可能にする
type Clock interface {
    Now() time.Time
}

type RealClock struct{}
func (r RealClock) Now() time.Time { return time.Now() }

type FakeClock struct {
    fakeTime time.Time
}
func (f FakeClock) Now() time.Time { return f.fakeTime }

// ファイルシステムStub
type FileSystem interface {
    ReadFile(name string) ([]byte, error)
    WriteFile(name string, data []byte, perm os.FileMode) error
    Exists(name string) bool
}

type OSFileSystem struct{}
func (f OSFileSystem) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
func (f OSFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
    return os.WriteFile(name, data, perm)
}
func (f OSFileSystem) Exists(name string) bool {
    _, err := os.Stat(name)
    return !os.IsNotExist(err)
}

// インメモリファイルシステムStub
type MemoryFileSystem struct {
    files map[string][]byte
}

func NewMemoryFileSystem() *MemoryFileSystem {
    return &MemoryFileSystem{files: make(map[string][]byte)}
}

func (m *MemoryFileSystem) ReadFile(name string) ([]byte, error) {
    data, ok := m.files[name]
    if !ok {
        return nil, fmt.Errorf("ファイルが見つかりません: %s", name)
    }
    return data, nil
}

func (m *MemoryFileSystem) WriteFile(name string, data []byte, perm os.FileMode) error {
    m.files[name] = data
    return nil
}

func (m *MemoryFileSystem) Exists(name string) bool {
    _, ok := m.files[name]
    return ok
}

// Stubを使ったサービステスト
type ConfigService struct {
    fs    FileSystem
    clock Clock
}

func (s *ConfigService) LoadConfig(path string) (*Config, error) {
    data, err := s.fs.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    cfg.LoadedAt = s.clock.Now()
    return &cfg, nil
}

8.2 データベースStub (sqlmock)

package repository_test

import (
    "context"
    "regexp"
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/stretchr/testify/assert"
)

func TestUserRepository_FindByID(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("sqlmockの作成に失敗: %v", err)
    }
    defer db.Close()

    rows := sqlmock.NewRows([]string{"id", "name", "email"}).
        AddRow(1, "Alice", "alice@example.com")

    mock.ExpectQuery(regexp.QuoteMeta("SELECT id, name, email FROM users WHERE id = ?")).
        WithArgs(1).
        WillReturnRows(rows)

    repo := NewSQLUserRepository(db)
    user, err := repo.FindByID(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)

    assert.NoError(t, mock.ExpectationsWereMet())
}

func TestUserRepository_Save(t *testing.T) {
    db, mock, _ := sqlmock.New()
    defer db.Close()

    mock.ExpectExec(regexp.QuoteMeta(
        "INSERT INTO users (name, email) VALUES (?, ?)")).
        WithArgs("Bob", "bob@example.com").
        WillReturnResult(sqlmock.NewResult(42, 1))

    repo := NewSQLUserRepository(db)
    user := &User{Name: "Bob", Email: "bob@example.com"}
    err := repo.Save(context.Background(), user)

    assert.NoError(t, err)
    assert.Equal(t, 42, user.ID)
    assert.NoError(t, mock.ExpectationsWereMet())
}

9. モックサーバーの構築

// mockserver/server.go
package mockserver

import (
    "encoding/json"
    "io"
    "net/http"
    "net/http/httptest"
    "sync"
    "testing"
)

type RequestRecord struct {
    Method  string
    Path    string
    Headers http.Header
    Body    []byte
}

type MockResponse struct {
    Status  int
    Headers map[string]string
    Body    interface{}
}

type Route struct {
    Method   string
    Path     string
    Response MockResponse
}

type MockServer struct {
    t        *testing.T
    server   *httptest.Server
    routes   []Route
    requests []RequestRecord
    mu       sync.Mutex
}

func New(t *testing.T) *MockServer {
    ms := &MockServer{t: t}

    mux := http.NewServeMux()
    mux.HandleFunc("/", ms.handleRequest)

    ms.server = httptest.NewServer(mux)
    t.Cleanup(ms.server.Close)

    return ms
}

func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    body, _ := io.ReadAll(r.Body)
    ms.requests = append(ms.requests, RequestRecord{
        Method:  r.Method,
        Path:    r.URL.Path,
        Headers: r.Header.Clone(),
        Body:    body,
    })

    for _, route := range ms.routes {
        if route.Method == r.Method && route.Path == r.URL.Path {
            for k, v := range route.Response.Headers {
                w.Header().Set(k, v)
            }
            if _, ok := route.Response.Headers["Content-Type"]; !ok {
                w.Header().Set("Content-Type", "application/json")
            }
            w.WriteHeader(route.Response.Status)
            if route.Response.Body != nil {
                json.NewEncoder(w).Encode(route.Response.Body)
            }
            return
        }
    }

    ms.t.Errorf("予期しないリクエスト: %s %s", r.Method, r.URL.Path)
    http.Error(w, "Not Found", http.StatusNotFound)
}

func (ms *MockServer) AddRoute(method, path string, resp MockResponse) *MockServer {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    ms.routes = append(ms.routes, Route{Method: method, Path: path, Response: resp})
    return ms
}

func (ms *MockServer) URL() string {
    return ms.server.URL
}

func (ms *MockServer) Requests() []RequestRecord {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    return append([]RequestRecord{}, ms.requests...)
}

func (ms *MockServer) AssertRequestCount(t *testing.T, method, path string, count int) {
    t.Helper()
    actual := 0
    for _, r := range ms.Requests() {
        if r.Method == method && r.Path == path {
            actual++
        }
    }
    if actual != count {
        t.Errorf("%s %s: リクエスト回数 = %d, want %d", method, path, actual, count)
    }
}

// 使用例
func TestWithMockServer(t *testing.T) {
    ms := New(t)

    ms.AddRoute("GET", "/api/users", MockResponse{
        Status: 200,
        Body:   []map[string]interface{}{{"id": 1, "name": "Alice"}},
    })
    ms.AddRoute("POST", "/api/users", MockResponse{
        Status: 201,
        Body:   map[string]interface{}{"id": 2, "name": "Bob"},
    })

    client := NewAPIClient(ms.URL())

    users, err := client.GetUsers()
    assert.NoError(t, err)
    assert.Len(t, users, 1)

    ms.AssertRequestCount(t, "GET", "/api/users", 1)
}

10. ベンチマークテスト

package calculator_test

import (
    "fmt"
    "strings"
    "testing"
)

// ベンチマーク: go test -bench=. で実行
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkFibonacci(b *testing.B) {
    sizes := []int{10, 20, 30}
    for _, n := range sizes {
        n := n
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Fibonacci(n)
            }
        })
    }
}

// メモリアロケーションの追跡
func BenchmarkStringConcat(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < 100; j++ {
            s += "x"
        }
        _ = s
    }
}

func BenchmarkStringBuilder(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

11. クイズ

クイズ1: assertとrequireの違い

assert.Equalrequire.Equalの違いは何ですか?それぞれいつ使うべきですか?

答え:

  • assert.Equal: 失敗してもテストを継続します。内部でt.Error()を呼び出します。
  • require.Equal: 失敗したらテストを即座に停止します。内部でt.FailNow()を呼び出します。

解説:

requireは後続のコードが前提条件に依存する場合に使います:

func TestUserCreation(t *testing.T) {
    user, err := CreateUser("Alice", "alice@example.com")
    require.NoError(t, err)     // エラーがあればuserはnilでパニックになる
    require.NotNil(t, user)

    assert.Equal(t, "Alice", user.Name)  // 独立した検証にはassertを使用
    assert.NotEmpty(t, user.ID)
}

assertは複数の独立した項目をすべて確認したい場合に使います。すべての失敗をまとめて確認できます。

クイズ2: テーブル駆動テストのクロージャの落とし穴

次のテーブル駆動テストのバグは何ですか?

tests := []struct {
    name  string
    input int
    want  int
}{
    {"double 2", 2, 4},
    {"double 5", 5, 10},
    {"double 10", 10, 20},
}

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := Double(tt.input)
        assert.Equal(t, tt.want, result)
    })
}

答え: t.Parallel()使用時に、クロージャがループ変数ttを共有します。

解説: t.Parallel()を呼び出すとサブテストが並行して実行されます。クロージャはttを参照でキャプチャするため、すべてのゴルーチンが同じ変数を共有します。ループが早く終了すると最後の値だけが残り、すべてのテストが同じ入力で実行される可能性があります。

修正方法:

for _, tt := range tests {
    tt := tt  // ループ変数をローカル変数にキャプチャ
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := Double(tt.input)
        assert.Equal(t, tt.want, result)
    })
}

Go 1.22以降ではループ変数のセマンティクスが変更され、この問題は解消されています。

クイズ3: MockとStubの違い

MockとStubの違いを説明し、それぞれいつ使うべきかを述べてください。

答え:

  • Stub: 事前に設定された応答を返すシンプルな代替物。状態検証 (state verification) に使用。
  • Mock: 特定のメソッドが特定の引数で呼ばれたか検証するオブジェクト。振る舞い検証 (behavior verification) に使用。

解説:

Stubの例 — 固定応答を返す:

type StubUserRepo struct{}
func (s StubUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
    return &User{ID: id, Name: "Test User"}, nil
}

Mockの例 — 呼び出しを検証:

mockRepo.EXPECT().FindByID(ctx, 1).Return(user, nil).Times(1)
// テスト後にFindByIDがちょうど1回呼ばれたか自動で検証

原則: メソッドの呼び出し有無・回数・引数を検証する必要があればMock、単に特定の応答が必要なだけであればStubを使います。

クイズ4: go test -race フラグの役割

go test -raceは何を検出し、いつ使うべきですか?

答え: レースコンディション(Race Condition)を検出します。複数のゴルーチンが同期なしに同じメモリ位置に並行してアクセスする場合を検知します。

解説:

// このコードは -race フラグで検出される
var counter int

func TestRaceCondition(t *testing.T) {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // DATA RACE: 同期なしで書き込み
        }()
    }
    wg.Wait()
}

-raceフラグは実行時オーバーヘッドが大きいため (5〜10倍遅くなる)、CI/CDパイプラインで実行するか、並行処理コードを書くときに定期的に使用するのがよいでしょう。

クイズ5: テストカバレッジ100%の落とし穴

テストカバレッジ100%はコードにバグがないことを保証しますか?カバレッジ測定時の注意点は?

答え: いいえ。カバレッジ100%はバグがないことを保証しません。

解説: カバレッジはコードが実行されたかどうかを測定するものであり、正しい動作を検証するものではありません。

func Divide(a, b int) int {
    return a / b
}

// カバレッジ100%だが重要なケースを見落としている
func TestDivide(t *testing.T) {
    assert.Equal(t, 5, Divide(10, 2))
    // b=0のケースをテストしていないため、実行時にパニックになる可能性がある
}

カバレッジより重要なこと:

  1. 境界値テスト (0, -1, 最大値など)
  2. エラーパスのテスト
  3. 並行処理のテスト
  4. 統合テスト

カバレッジはテストされていないコードを見つけるためのツールであり、テストの品質を保証するものではありません。意味のあるテストで70%のカバレッジを達成する方が、形式的なテストで100%を達成するよりはるかに価値があります。