こんにちは!フリーランスエンジニアのこたろうです。
今回は、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エンドポイントが正しく動作することを確認する必要があります:
-
リクエストの処理が正しいか
- パラメータの解析
- JSONデータのバインディング
- バリデーションチェック
-
適切なレスポンスを返すか
- ステータスコードが正しい
- JSONの形式が期待通り
- エラーメッセージが適切
-
エラーケースの処理
- 不正なリクエスト
- データが存在しない場合
- サーバーエラーの場合
テスト環境のセットアップ詳細
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には特別なエラータイプがあります:
-
gorm.ErrRecordNotFound
- レコードが見つからない場合
- 通常はエラーとして扱わない
- ユーザーに適切なメッセージを返す
-
一般的なデータベースエラー
- 接続エラー
- 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)
}
エラーメッセージの設計
- クライアントエラー(4xx)
{
"error": "Invalid request",
"details": [
"Title is required",
"Description must be less than 1000 characters"
],
"code": "VALIDATION_ERROR"
}
- サーバーエラー(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))
}
まとめ:テストの柔軟性を高めるポイント
-
構造化されたテストケース
- テーブル駆動テストによるパターン化
- 境界値とエッジケースの網羅
- テストケースの意図を明確に記述
-
効率的なテストデータ管理
- ビルダーパターンによる柔軟なデータ生成
- カスタムマッチャーによる柔軟な検証
- テストデータの再利用性向上
-
テストの保守性向上
- ヘルパー関数による共通処理の抽出
- テストスイートによるライフサイクル管理
- クリーンな後処理の保証
-
デバッグのしやすさ
- 明確なエラーメッセージ
- 失敗時の原因特定が容易
- テストケースの独立性確保
これらのテクニックを組み合わせることで、保守性が高く、信頼性のあるテストコードを実現できます。