【Go/GORM】sqlmockを使ったユニットテストの実践的な書き方


こんにちは！フリーランスエンジニアのこたろうです。
データベースを使用したアプリケーションのテストについて、学びで得た知見を共有します。

## なぜsqlmockが必要なのか？

データベースを使用するアプリケーションのテストには、以下のような課題があります：

1. テスト用のデータベース環境が必要
2. テストの実行に時間がかかる
3. テストデータの準備が大変
4. 特定の状況（エラーケースなど）のテストが難しい

これらの課題を解決するのが`sqlmock`です。`sqlmock`を使うと：
- 実際のデータベース不要
- テストの実行が高速
- あらゆるケースのテストが可能
になります。

## sqlmockの基本的な使い方

### セットアップ方法
まず、テスト用のデータベース接続をモック化します：

```go
func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
    // モックのDB接続を作成
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("sqlmockの作成に失敗: %v", err)
    }

    // GORMでモックDBを扱えるようにする
    gormDB, err := gorm.Open(postgres.New(postgres.Config{
        Conn: db,
    }), &gorm.Config{})
    if err != nil {
        t.Fatalf("GORMの初期化に失敗: %v", err)
    }

    return gormDB, mock
}
```

このセットアップ関数が行っていることは：
1. sqlmockでモックのデータベース接続を作成
2. そのモック接続をGORMで使えるように変換
3. テストで使用するDB接続とモックを返す

### 基本的なテストの書き方

タスク作成のテストを例に見てみましょう：

```go
func TestCreateTask(t *testing.T) {
    // 1. セットアップ
    db, mock := setupMockDB(t)
    repo := NewTaskRepository(db)

    // 2. テストデータの準備
    task := &Task{
        Title:       "テストタスク",
        Description: "詳細説明",
    }

    // 3. モックの期待値を設定
    // INSERT文が実行されることを期待
    mock.ExpectExec(`INSERT INTO "tasks" \("title","description"\) VALUES \(\$1,\$2\) RETURNING "id"`).
        // プレースホルダの値を指定
        WithArgs(task.Title, task.Description).
        // 戻り値を設定（ID=1, 影響行数=1）
        WillReturnResult(sqlmock.NewResult(1, 1))

    // 4. テスト対象の関数を実行
    err := repo.Create(task)
    
    // 5. 検証
    assert.NoError(t, err)                          // エラーが発生していないこと
    assert.NoError(t, mock.ExpectationsWereMet())   // モックの期待値通りに実行されたこと
}
```

このテストが確認していることは：
1. 正しいSQL文が実行されるか
2. SQLのパラメータが正しいか
3. エラーが発生しないか
4. モックの期待値通りに処理が行われたか

## よくある課題と解決策

### 1. 時刻フィールドの扱い
GORMは`created_at`や`updated_at`を自動で設定します。これらの時刻は常に変化するため、テストが不安定になりがちです。

```go
// 問題のあるコード
mock.ExpectExec(`UPDATE "tasks"`).
    WithArgs(task.Title, time.Now()).  // ← これは失敗しやすい
    WillReturnResult(sqlmock.NewResult(1, 1))

// 改善コード
mock.ExpectExec(`UPDATE "tasks"`).
    WithArgs(task.Title, sqlmock.AnyArg()).  // どんな時刻でもOK
    WillReturnResult(sqlmock.NewResult(1, 1))

// さらに良いコード
now := time.Now().UTC().Truncate(time.Microsecond)
task.UpdatedAt = now
mock.ExpectExec(`UPDATE "tasks"`).
    WithArgs(task.Title, now).  // 具体的な時刻を指定
    WillReturnResult(sqlmock.NewResult(1, 1))
```

### 2. トランザクションのテスト
トランザクションを使用する場合、Begin/Commit（またはRollback）の期待値も設定する必要があります：

```go
func TestCreateTaskWithTransaction(t *testing.T) {
    db, mock := setupMockDB(t)
    repo := NewTaskRepository(db)

    task := &Task{Title: "トランザクションテスト"}

    // トランザクションの流れを定義
    mock.ExpectBegin()  // トランザクション開始
    mock.ExpectExec(`INSERT INTO "tasks"`).
        WithArgs(task.Title, sqlmock.AnyArg(), sqlmock.AnyArg()).
        WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit() // トランザクション終了

    err := repo.CreateWithTransaction(task)
    assert.NoError(t, err)
}
```

### 3. エラー処理のテスト
データベースエラーの場合のテストも書けます：

```go
func TestCreateTask_Error(t *testing.T) {
    db, mock := setupMockDB(t)
    repo := NewTaskRepository(db)

    task := &Task{Title: "エラーテスト"}

    // エラーを返すように設定
    mock.ExpectExec(`INSERT INTO "tasks"`).
        WithArgs(task.Title).
        WillReturnError(fmt.Errorf("データベースエラー"))

    err := repo.Create(task)
    assert.Error(t, err) // エラーが返されることを確認
    assert.Contains(t, err.Error(), "データベースエラー")
}
```

## まとめ：効果的なテストを書くためのポイント

1. **SQL文の正確な把握**
   - 実際に実行されるSQLを確認
   - 正規表現のパターンを正確に
   - プレースホルダの順序に注意

2. **時刻データの取り扱い**
   - `time.Now()`は`UTC()`と`Truncate()`を使用
   - 必要に応じて`sqlmock.AnyArg()`を活用

3. **トランザクション管理**
   - Begin/Commitの順序を正しく
   - ロールバックのテストも忘れずに

4. **エラーケースのテスト**
   - データベースエラーの場合の動作確認
   - バリデーションエラーの確認
   - エッジケースの考慮

sqlmockを使いこなすことで、データベース操作を含むコードでも、安定した高品質なテストが書けるようになります。