0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

1. テストが必要な理由

アプリケーションを開発する際、以下のような不安が出てきます:

  • 機能追加で既存の機能が壊れていないか?
  • バグ修正が他の機能に影響を与えていないか?
  • コードの品質は保たれているか?

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

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

モックとは?

実際のデータベースやAPIを使わずに、その動作を模倣するものです。
例えば:

  • 実際:アプリ → データベース → データ取得
  • テスト時:アプリ → モック → テストデータ取得

モックの基本実装

// モックの定義
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)
}

モックの使い方

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. エラーケースの処理

    • 不正なリクエスト
    • データが存在しない場合
    • サーバーエラーの場合

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

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
}

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

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リクエストのテスト例

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の構文エラー
    • 一意性制約違反

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

// サービス層でのエラーハンドリング
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)
{
    "error": "Invalid request",
    "details": [
        "Title is required",
        "Description must be less than 1000 characters"
    ],
    "code": "VALIDATION_ERROR"
}
  1. サーバーエラー(5xx)
{
    "error": "Internal server error",
    "request_id": "abc-123",  // デバッグ用
    "code": "INTERNAL_ERROR"
}

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

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

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

メリット

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

境界値テストの例

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. モックの柔軟な設定

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

カスタムマッチャーの作成

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. ビルダーパターンによるテストデータ作成

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

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テスト用ヘルパー

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. テストスイートの活用

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

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. デバッグのしやすさ

    • 明確なエラーメッセージ
    • 失敗時の原因特定が容易
    • テストケースの独立性確保

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?