【Go】実践的なユニットテストとGinを使ったAPIテスト - 基礎から実装まで

こんにちは！フリーランスエンジニアのこたろうです。
今回は、Goのテストコードの書き方について、基礎から実践的な実装方法まで解説します。

## 1. テストが必要な理由

アプリケーションを開発する際、以下のような不安が出てきます：
- 機能追加で既存の機能が壊れていないか？
- バグ修正が他の機能に影響を与えていないか？
- コードの品質は保たれているか？

これらの不安を解消するのが「テスト」です。

## 2. testify/mockを使ったモックの活用

### モックとは？
実際のデータベースやAPIを使わずに、その動作を模倣するものです。
例えば：
- 実際：アプリ → データベース → データ取得
- テスト時：アプリ → モック → テストデータ取得

### モックの基本実装
```go
// モックの定義
type mockTaskRepository struct {
    mock.Mock  // testifyのモック機能を組み込む
}

// FindAllTasksのモック実装
func (m *mockTaskRepository) FindAllTasks() ([]models.Task, error) {
    // Called()でモックの呼び出しを記録
    args := m.Called()
    
    // nilチェックは重要！
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    
    // 戻り値の型変換
    return args.Get(0).([]models.Task), args.Error(1)
}
```

### モックの使い方
```go
func TestGetTasks(t *testing.T) {
    // モックの準備
    mockRepo := new(mockTaskRepository)
    
    // テストデータの準備
    testTasks := []models.Task{
        {ID: 1, Title: "タスク1"},
        {ID: 2, Title: "タスク2"},
    }
    
    // モックの振る舞いを定義
    mockRepo.On("FindAllTasks").Return(testTasks, nil)
    
    // テスト実行
    tasks, err := mockRepo.FindAllTasks()
    
    // 結果の検証
    assert.NoError(t, err)
    assert.Len(t, tasks, 2)
    assert.Equal(t, "タスク1", tasks[0].Title)
}
```

## 3. Ginを使ったAPIテスト

### APIテストの重要性

実際のアプリケーションでは、APIエンドポイントが正しく動作することを確認する必要があります：

1. **リクエストの処理が正しいか**
   - パラメータの解析
   - JSONデータのバインディング
   - バリデーションチェック

2. **適切なレスポンスを返すか**
   - ステータスコードが正しい
   - JSONの形式が期待通り
   - エラーメッセージが適切

3. **エラーケースの処理**
   - 不正なリクエスト
   - データが存在しない場合
   - サーバーエラーの場合

### テスト環境のセットアップ詳細

```go
func setupTestContext() (*gin.Engine, *mockTaskService, *controllers.TaskController) {
    // テストモードの設定
    // - ログ出力を抑制
    // - パフォーマンスの向上
    gin.SetMode(gin.TestMode)
    
    // モックサービスとコントローラの作成
    mockService := new(mockTaskService)
    controller := controllers.NewTaskController(mockService)

    // ルーターの設定
    router := gin.Default()
    
    // 各エンドポイントの登録
    router.GET("/tasks", controller.GetTasks)
    router.POST("/tasks", controller.CreateTask)
    router.PUT("/tasks/:id", controller.UpdateTask)
    router.DELETE("/tasks/:id", controller.DeleteTask)

    return router, mockService, controller
}
```

### リクエストとレスポンスのテスト実装

```go
func TestGetTasks_Success(t *testing.T) {
    router, mockService, _ := setupTestContext()

    // テストデータ
    tasks := []models.Task{
        {ID: 1, Title: "タスク1", Description: "説明1"},
        {ID: 2, Title: "タスク2", Description: "説明2"},
    }

    // モックの設定
    mockService.On("GetAllTasks").Return(tasks, nil)

    // httptest.NewRecorderの役割:
    // - HTTPレスポンスを記録
    // - ステータスコード、ヘッダー、ボディを保持
    w := httptest.NewRecorder()
    
    // http.NewRequestの各パラメータ:
    // - HTTPメソッド（GET, POST, PUT, DELETE）
    // - エンドポイントのパス
    // - リクエストボディ（GETの場合はnil）
    req, _ := http.NewRequest("GET", "/tasks", nil)
    
    // リクエストの実行
    // - 実際のHTTPサーバーを起動せずにテスト可能
    router.ServeHTTP(w, req)

    // レスポンスの検証
    assert.Equal(t, http.StatusOK, w.Code)
    
    var response []models.Task
    json.Unmarshal(w.Body.Bytes(), &response)
    
    // レスポンスの内容を詳細に検証
    assert.Len(t, response, 2)
    assert.Equal(t, "タスク1", response[0].Title)
    assert.Equal(t, "説明1", response[0].Description)
}
```

### POSTリクエストのテスト例

```go
func TestCreateTask_Success(t *testing.T) {
    router, mockService, _ := setupTestContext()

    // リクエストボディの準備
    input := models.CreateTaskInput{
        Title:       "新しいタスク",
        Description: "説明文",
    }
    
    // JSONに変換
    jsonInput, _ := json.Marshal(input)
    
    // POSTリクエストの作成
    // - Content-Typeヘッダーの設定が重要
    req, _ := http.NewRequest("POST", "/tasks", bytes.NewBuffer(jsonInput))
    req.Header.Set("Content-Type", "application/json")
    
    // モックの設定
    expectedTask := &models.Task{
        ID:          1,
        Title:       input.Title,
        Description: input.Description,
    }
    mockService.On("CreateTask", mock.AnythingOfType("*models.Task")).
        Return(expectedTask, nil)

    // リクエスト実行
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)

    // レスポンスの検証
    assert.Equal(t, http.StatusCreated, w.Code)
    
    var response models.Task
    json.Unmarshal(w.Body.Bytes(), &response)
    assert.Equal(t, expectedTask.Title, response.Title)
}
```

## 4. エラーハンドリングのベストプラクティス

### GORMのエラーハンドリング

GORMには特別なエラータイプがあります：

1. **gorm.ErrRecordNotFound**
   - レコードが見つからない場合
   - 通常はエラーとして扱わない
   - ユーザーに適切なメッセージを返す

2. **一般的なデータベースエラー**
   - 接続エラー
   - SQLの構文エラー
   - 一意性制約違反

### エラーハンドリングの実装例

```go
// サービス層でのエラーハンドリング
func (s *TaskService) FindTaskByID(id int) (*models.Task, error) {
    var task models.Task
    
    result := s.db.First(&task, id)
    if result.Error != nil {
        // レコードが見つからない場合
        if errors.Is(result.Error, gorm.ErrRecordNotFound) {
            return nil, &NotFoundError{
                Resource: "Task",
                ID:      id,
            }
        }
        // その他のデータベースエラー
        return nil, fmt.Errorf("failed to find task: %w", result.Error)
    }
    
    return &task, nil
}

// コントローラー層でのエラーハンドリング
func (c *TaskController) GetTask(ctx *gin.Context) {
    id, err := strconv.Atoi(ctx.Param("id"))
    if err != nil {
        ctx.JSON(http.StatusBadRequest, gin.H{
            "error": "Invalid task ID",
            "details": "ID must be a number",
        })
        return
    }

    task, err := c.service.FindTaskByID(id)
    if err != nil {
        switch {
        case errors.As(err, &NotFoundError{}):
            ctx.JSON(http.StatusNotFound, gin.H{
                "error": err.Error(),
            })
        default:
            ctx.JSON(http.StatusInternalServerError, gin.H{
                "error": "Internal server error",
            })
        }
        return
    }

    ctx.JSON(http.StatusOK, task)
}
```

### エラーメッセージの設計

1. **クライアントエラー（4xx）**
```json
{
    "error": "Invalid request",
    "details": [
        "Title is required",
        "Description must be less than 1000 characters"
    ],
    "code": "VALIDATION_ERROR"
}
```

2. **サーバーエラー（5xx）**
```json
{
    "error": "Internal server error",
    "request_id": "abc-123",  // デバッグ用
    "code": "INTERNAL_ERROR"
}
```

# 5. テストの柔軟性を高めるテクニック

## 1. テーブル駆動テストによるパターン化

テーブル駆動テストとは、複数のテストケースを一つの構造体スライスにまとめて、それを繰り返し実行する手法です。

### メリット
- テストケースの追加が容易
- パターンの網羅性が可視化
- コードの重複を削減
- テストの意図が明確

### 境界値テストの例
```go
func TestTaskValidation(t *testing.T) {
    tests := []struct {
        name    string
        input   TaskInput
        wantErr bool
        errMsg  string
    }{
        {
            name: "正常系：最小値での入力",
            input: TaskInput{
                Title:       "a",          // 最小長(1文字)
                Priority:    1,            // 最低優先度
                DueDate:     time.Now(),   // 現在時刻
            },
            wantErr: false,
        },
        {
            name: "正常系：最大値での入力",
            input: TaskInput{
                Title:       strings.Repeat("a", 100), // 最大長
                Priority:    5,                        // 最高優先度
                DueDate:     time.Now().AddDate(1, 0, 0), // 1年後
            },
            wantErr: false,
        },
        {
            name: "異常系：タイトルが長すぎる",
            input: TaskInput{
                Title:       strings.Repeat("a", 101), // 最大長+1
                Priority:    1,
                DueDate:     time.Now(),
            },
            wantErr: true,
            errMsg:  "title must be less than 100 characters",
        },
        // エッジケースのテスト
        {
            name: "異常系：優先度が範囲外",
            input: TaskInput{
                Title:       "タスク",
                Priority:    6, // 範囲外の値
                DueDate:     time.Now(),
            },
            wantErr: true,
            errMsg:  "priority must be between 1 and 5",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validateTask(tt.input)
            if tt.wantErr {
                assert.Error(t, err)
                assert.Contains(t, err.Error(), tt.errMsg)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}
```

## 2. モックの柔軟な設定

モックをより柔軟に、かつ読みやすく設定する方法を解説します。

### カスタムマッチャーの作成
```go
func TaskMatcher(expected Task) interface{} {
    return mock.MatchedBy(func(actual Task) bool {
        // タイトルと説明の部分一致をチェック
        titleMatch := strings.Contains(actual.Title, expected.Title)
        descMatch := strings.Contains(actual.Description, expected.Description)
        
        // ステータスは完全一致
        statusMatch := actual.Status == expected.Status
        
        // 日付は誤差を許容
        dateMatch := actual.DueDate.Sub(expected.DueDate) < time.Minute
        
        return titleMatch && descMatch && statusMatch && dateMatch
    })
}

// 使用例
func TestCreateTask(t *testing.T) {
    mockRepo := new(MockTaskRepository)
    expected := Task{
        Title:       "新規タスク",
        Description: "説明",
        Status:      "未着手",
        DueDate:     time.Now().Add(24 * time.Hour),
    }
    
    // カスタムマッチャーを使用
    mockRepo.On("Create", TaskMatcher(expected)).Return(nil)
    
    service := NewTaskService(mockRepo)
    actual := Task{
        Title:       "新規タスク(追加情報)",
        Description: "説明と補足",
        Status:      "未着手",
        DueDate:     expected.DueDate.Add(30 * time.Second),
    }
    
    err := service.CreateTask(actual)
    assert.NoError(t, err)
}
```

## 3. ビルダーパターンによるテストデータ作成

テストデータの作成を柔軟かつ可読性高く行うためのビルダーパターンを実装します。

```go
type TaskBuilder struct {
    task Task
}

func NewTaskBuilder() *TaskBuilder {
    return &TaskBuilder{
        task: Task{
            Status:    "未着手",
            Priority:  1,
            CreatedAt: time.Now(),
        },
    }
}

// メソッドチェーンでの柔軟な設定
func (b *TaskBuilder) WithTitle(title string) *TaskBuilder {
    b.task.Title = title
    return b
}

func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder {
    b.task.Priority = priority
    return b
}

func (b *TaskBuilder) WithDueDate(dueDate time.Time) *TaskBuilder {
    b.task.DueDate = dueDate
    return b
}

func (b *TaskBuilder) WithStatus(status string) *TaskBuilder {
    b.task.Status = status
    return b
}

// カスタムビルダーメソッド
func (b *TaskBuilder) AsHighPriority() *TaskBuilder {
    b.task.Priority = 5
    b.task.Title = "[重要] " + b.task.Title
    return b
}

func (b *TaskBuilder) AsUrgent() *TaskBuilder {
    b.task.Priority = 5
    b.task.Title = "[緊急] " + b.task.Title
    b.task.DueDate = time.Now().Add(24 * time.Hour)
    return b
}

func (b *TaskBuilder) Build() Task {
    return b.task
}

// 使用例
func TestTaskProcessing(t *testing.T) {
    // 通常のタスク
    normalTask := NewTaskBuilder().
        WithTitle("通常タスク").
        WithPriority(3).
        Build()
    
    // 緊急タスク
    urgentTask := NewTaskBuilder().
        WithTitle("緊急の対応").
        AsUrgent().
        Build()
    
    // テストケース実行...
}
```

## 4. テストヘルパー関数の高度な使用法

テストの準備や実行を効率化するヘルパー関数を実装します。

### HTTPテスト用ヘルパー
```go
type TestRequest struct {
    method  string
    path    string
    body    interface{}
    headers map[string]string
}

func ExecuteRequest(router *gin.Engine, req TestRequest) *httptest.ResponseRecorder {
    // レスポンスレコーダーの作成
    w := httptest.NewRecorder()
    
    // リクエストの作成
    var bodyReader io.Reader
    if req.body != nil {
        jsonBody, _ := json.Marshal(req.body)
        bodyReader = bytes.NewBuffer(jsonBody)
    }
    
    httpReq := httptest.NewRequest(req.method, req.path, bodyReader)
    
    // ヘッダーの設定
    for key, value := range req.headers {
        httpReq.Header.Set(key, value)
    }
    
    // Content-Typeの自動設定
    if req.body != nil && req.headers["Content-Type"] == "" {
        httpReq.Header.Set("Content-Type", "application/json")
    }
    
    // リクエストの実行
    router.ServeHTTP(w, httpReq)
    return w
}

// レスポンス検証用ヘルパー
func AssertResponse(t *testing.T, w *httptest.ResponseRecorder, expectedCode int, expectedBody interface{}) {
    // ステータスコードの検証
    assert.Equal(t, expectedCode, w.Code)
    
    // レスポンスボディの検証
    if expectedBody != nil {
        var actualBody interface{}
        err := json.Unmarshal(w.Body.Bytes(), &actualBody)
        assert.NoError(t, err)
        assert.Equal(t, expectedBody, actualBody)
    }
}

// 使用例
func TestCreateTask_Success(t *testing.T) {
    router, _ := setupTestRouter()
    
    input := TaskInput{
        Title:       "新規タスク",
        Description: "説明文",
    }
    
    w := ExecuteRequest(router, TestRequest{
        method: "POST",
        path:   "/tasks",
        body:   input,
    })
    
    expectedResponse := gin.H{
        "message": "Task created successfully",
        "id":      1,
    }
    
    AssertResponse(t, w, http.StatusCreated, expectedResponse)
}
```

## 5. テストスイートの活用

関連するテストをグループ化し、共通の設定や後処理を効率的に行います。

```go
type TaskTestSuite struct {
    suite.Suite
    router *gin.Engine
    mock   *MockTaskService
    db     *gorm.DB
}

func (s *TaskTestSuite) SetupSuite() {
    // テストスイート全体の初期化
    s.db = setupTestDB()
    s.mock = new(MockTaskService)
    s.router = setupTestRouter(s.mock)
}

func (s *TaskTestSuite) TearDownSuite() {
    // テストスイート終了時の後処理
    s.db.Close()
}

func (s *TaskTestSuite) SetupTest() {
    // 各テスト実行前の準備
    s.db.Exec("TRUNCATE tasks")
    s.mock.ExpectedCalls = nil
}

func (s *TaskTestSuite) TestCreateTask_Success() {
    // テストケースの実装
    input := NewTaskBuilder().
        WithTitle("新規タスク").
        AsHighPriority().
        Build()
    
    w := ExecuteRequest(s.router, TestRequest{
        method: "POST",
        path:   "/tasks",
        body:   input,
    })
    
    AssertResponse(s.T(), w, http.StatusCreated, nil)
}

// テストスイートの実行
func TestTaskSuite(t *testing.T) {
    suite.Run(t, new(TaskTestSuite))
}
```

## まとめ：テストの柔軟性を高めるポイント

1. **構造化されたテストケース**
   - テーブル駆動テストによるパターン化
   - 境界値とエッジケースの網羅
   - テストケースの意図を明確に記述

2. **効率的なテストデータ管理**
   - ビルダーパターンによる柔軟なデータ生成
   - カスタムマッチャーによる柔軟な検証
   - テストデータの再利用性向上

3. **テストの保守性向上**
   - ヘルパー関数による共通処理の抽出
   - テストスイートによるライフサイクル管理
   - クリーンな後処理の保証

4. **デバッグのしやすさ**
   - 明確なエラーメッセージ
   - 失敗時の原因特定が容易
   - テストケースの独立性確保

これらのテクニックを組み合わせることで、保守性が高く、信頼性のあるテストコードを実現できます。