Go言語でモックを使った高度なテスト：sqlmockを活用したNewspaperモデルのエラーハンドリング徹底解説

## はじめに

この記事では、Go言語を使ったモックテストを徹底解説します。特にデータベース操作のエラーハンドリングに焦点を当て、`sqlmock`ライブラリを活用して、`Newspaper`モデルの動作をどのようにテストするかを具体例を交えて詳しく解説します。

## 記事の構成

1. **なぜモックが必要なのか？**  
   - 実際のデータベースとモックデータベースの違い
2. **`tester`パッケージの実装**  
   - モックデータベースの作成方法  
   - サポート関数やユーティリティの解説
3. **`Newspaper`モデルのモックテスト実装**  
   - テストスイートの作成  
   - エラーケースをシミュレーションする手法
4. **コード一行ずつの解説**  
   - 各セクションの処理内容を詳細に説明
5. **テスト結果と考察**  
   - 実行例と得られる知見

## 1. なぜモックが必要なのか？

テストコードを書く際に、実際のデータベースを利用すると以下の課題があります：
- **速度の問題**: 実際のデータベースへのアクセスは遅い。
- **データ汚染**: テストによって実際のデータが書き換えられる危険がある。
- **再現性の難しさ**: データベースの状態に依存するため、テストの結果が一貫しないことがある。

モックデータベースを使えば、これらの問題を解消できます。`sqlmock`は、SQLクエリをモックするためのライブラリで、特定のクエリやエラーをシミュレーションでき、再現性が高いテスト環境を構築するのに最適です。

## 2. `tester`パッケージの実装

まずは、`tester`パッケージでモックデータベースを作成するコードを見てみましょう。

```go
package tester  

import (  
	"time"  
	"github.com/DATA-DOG/go-sqlmock"  
	"gorm.io/driver/mysql"  
	"gorm.io/gorm"  
	"go-api-newspaper/pkg/logger"  
)  

// MockDB関数はモックデータベースを生成し、SQLモックとGORMモックを返します。
func MockDB() (mock sqlmock.Sqlmock, mockGormDB *gorm.DB) {  
	// sqlmock.NewでSQLモックインスタンスを作成します。
	mockDB, mock, err := sqlmock.New(  
		sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))  
	if err != nil {  
		logger.Fatal(err.Error())  
	}  

	// GORMでモックデータベースを使用する設定をします。
	mockGormDB, err = gorm.Open(mysql.New(mysql.Config{  
		DSN:                       "mock_db",  
		DriverName:                "mysql",  
		Conn:                      mockDB,  
		SkipInitializeWithVersion: true,  
	}), &gorm.Config{})  
	if err != nil {  
		logger.Fatal(err.Error())  
	}  
	return mock, mockGormDB  
}  

// mockClock構造体は、特定の時刻を返すカスタムクロックを作成します。
type mockClock struct {  
	t time.Time  
}  

// NewMockClock関数はmockClockのインスタンスを生成します。
func NewMockClock(t time.Time) mockClock {  
	return mockClock{t}  
}  

// Nowメソッドは現在時刻として設定された時刻を返します。
func (m mockClock) Now() time.Time {  
	return m.t  
}  
```
### このセクションのポイント：
1. **`MockDB`関数**:  
   SQLモックとGORMを組み合わせて、データベース操作を完全にモック化。これにより、外部データベースに依存せずに動作をテスト可能。
2. **`mockClock`構造体**:  
   特定の時刻をテスト時に固定するために使用。時間依存のロジックがある場合に役立つ。


## 3. `Newspaper`モデルのモックテスト実装

ここでは、実際に`Newspaper`モデルのCRUD操作をテストするコードを紹介します。特に、失敗ケース（エラーハンドリング）を中心に解説します。

```go
package models_test  

import (  
	"errors"  
	"fmt"  
	"regexp"  
	"strings"  
	"testing"  

	"github.com/DATA-DOG/go-sqlmock"  
	"github.com/stretchr/testify/suite"  
	"gorm.io/gorm"  

	"go-api-newspaper/app/models"  
	"go-api-newspaper/pkg/tester"  
)  

// NewspaperTestSuiteは、`Newspaper`モデルのテストケースを格納する構造体です。
type NewspaperTestSuite struct {  
	tester.DBSQLiteSuite  
	originalDB *gorm.DB  
}  

// TestNewspaperTestSuiteは、テストスイートを実行します。
func TestNewspaperTestSuite(t *testing.T) {  
	suite.Run(t, new(NewspaperTestSuite))  
}  

// SetupSuiteは、テストの前に実行される初期化処理を定義します。
func (suite *NewspaperTestSuite) SetupSuite() {  
	suite.DBSQLiteSuite.SetupSuite()  
	suite.originalDB = models.DB  
}  

// MockDB関数をテスト環境で使えるようにします。
func (suite *NewspaperTestSuite) MockDB() sqlmock.Sqlmock {  
	mock, mockGormDB := tester.MockDB()  
	models.DB = mockGormDB  
	return mock  
}  

// AfterTestは各テスト終了後に元の状態に戻します。
func (suite *NewspaperTestSuite) AfterTest(suiteName, testName string) {  
	models.DB = suite.originalDB  
}  
```

### 各テストケースの目的と動作
```go
func (suite *NewspaperTestSuite) TestNewspaperCreateFailure() {
    mockDB := suite.MockDB()
    mockDB.ExpectBegin() // トランザクションの開始を期待
    mockDB.ExpectExec("INSERT INTO `newspapers`").WithArgs("Test", "sports").WillReturnError(errors.New("create error"))
    // トランザクションのロールバックやコミット操作を期待
    mockDB.ExpectRollback()
    mockDB.ExpectCommit()

    newspaper, err := models.CreateNewspaper("Test", "sports")
	suite.Assert().Nil(newspaper)
    suite.Assert().NotNil(err)
    suite.Assert().Equal("create error", err.Error())
}
```
**TestNewspaperCreateFailure**  
このテストは、記事を新規作成する際にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。

1. **mockDBの設定**  
   `mockDB := suite.MockDB()`でモックデータベースを初期化します。

2. **ExpectBeginの設定**  
   モックがトランザクションの開始を期待することを設定します。

3. **ExpectExecの設定**  
   `INSERT INTO`クエリを期待し、指定した引数（"Test"と"sports"）が渡される場合にエラー`"create error"`を返すように設定します。

4. **ExpectRollbackの設定**  
   クエリが失敗した際にトランザクションがロールバックされることを期待します。

5. **CreateNewspaperの実行**  
   `models.CreateNewspaper("Test", "sports")`を呼び出します。

6. **アサーション**  
   - 作成された記事が`nil`であることを確認。
   - エラーが発生したことを確認。
   - エラーメッセージが`"create error"`であることを確認。

```go
func (suite *NewspaperTestSuite) TestNewspaperGetFailure() {
	mockDB := suite.MockDB()
	// SQLクエリの期待値を設定。このクエリが実行されると、エラー"get error"が返される
	mockDB.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `newspapers` WHERE `newspapers`.`id` = ? ORDER BY `newspapers`.`id` LIMIT ?")).WithArgs(1, 1).WillReturnError(errors.New("get error"))

	newspaper, err := models.GetNewspaper(1)
	suite.Assert().Nil(newspaper)
	suite.Assert().NotNil(err)
	suite.Assert().Equal("get error", err.Error())
}
```
**TestNewspaperGetFailure**  
記事取得時にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。

1. **mockDBの設定**  
   `mockDB := suite.MockDB()`でモックデータベースを初期化します。

2. **ExpectQueryの設定**  
   `SELECT * FROM`クエリを期待し、引数`1`が渡される場合にエラー`"get error"`を返すよう設定します。

3. **GetNewspaperの実行**  
   `models.GetNewspaper(1)`を呼び出します。

4. **アサーション**  
   - 取得された記事が`nil`であることを確認。
   - エラーが発生したことを確認。
   - エラーメッセージが`"get error"`であることを確認。

```go
func (suite *NewspaperTestSuite) TestNewspaperSaveFailure() {
    mockDB := suite.MockDB()
    mockDB.ExpectBegin() // トランザクションの開始を期待
    mockDB.ExpectExec(regexp.QuoteMeta("UPDATE `newspapers` SET `title`=?,`column_name`=? WHERE `id` = ?")).WithArgs("updated", "sports", 1).WillReturnError(errors.New("update error"))
    // トランザクションのロールバックやコミット操作を期待
    mockDB.ExpectRollback()

    newspaper := models.Newspaper{
        ID:         1,
        Title:      "Test",
        ColumnName: "sports",
    }
    newspaper.Title = "updated"
    err := newspaper.Save()
    suite.Assert().NotNil(err)
    suite.Assert().Equal("update error", err.Error())
}
```
**TestNewspaperSaveFailure**  
記事の保存時にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。

1. **mockDBの設定**  
   `mockDB := suite.MockDB()`でモックデータベースを初期化します。

2. **ExpectBeginの設定**  
   トランザクションの開始を期待します。

3. **ExpectExecの設定**  
   `UPDATE`クエリを期待し、引数（"updated", "sports", 1）が渡される場合にエラー`"update error"`を返すように設定します。

4. **ExpectRollbackの設定**  
   クエリが失敗した際にトランザクションがロールバックされることを期待します。

5. **Saveの実行**  
   新聞記事のタイトルを変更後に`newspaper.Save()`を呼び出します。

6. **アサーション**  
   - エラーが発生したことを確認。
   - エラーメッセージが`"update error"`であることを確認。

```go
func (suite *NewspaperTestSuite) TestNewspaperDeleteFailure() {
	mockDB := suite.MockDB()
	mockDB.ExpectBegin() // トランザクションの開始を期待
	mockDB.ExpectExec("DELETE FROM `newspapers` WHERE id = ?").WithArgs(0).WillReturnError(errors.New("delete error"))
	// トランザクションのロールバックやコミット操作を期待
	mockDB.ExpectRollback()
	mockDB.ExpectCommit()

	newspaper := models.Newspaper{
		Title:       "Test",
		ColumnName:  "sports",
	}

	err := newspaper.Delete()
	suite.Assert().NotNil(err)
	suite.Assert().Equal("delete error", err.Error())
}
```

**TestNewspaperDeleteFailure**  
記事削除時にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。

1. **mockDBの設定**  
   `mockDB := suite.MockDB()`でモックデータベースを初期化します。

2. **ExpectBeginの設定**  
   トランザクションの開始を期待します。

3. **ExpectExecの設定**  
   `DELETE`クエリを期待し、引数`0`が渡される場合にエラー`"delete error"`を返すように設定します。

4. **ExpectRollbackの設定**  
   クエリが失敗した際にトランザクションがロールバックされることを期待します。

5. **Deleteの実行**  
   新聞記事を削除する`newspaper.Delete()`を呼び出します。

6. **アサーション**  
   - エラーが発生したことを確認。
   - エラーメッセージが`"delete error"`であることを確認。


## 4. テスト結果と考察

テストを実行すると、以下の結果が得られます：

- データベース操作がモックデータベースで正しく動作する。
- 期待されるエラーメッセージが返る。
- モックを活用することで外部依存を排除し、テストの再現性が確保される。

## まとめ

これで、モックを利用した`Newspaper`モデルのテストが完成しました。この方法を参考に、他のモデルやユースケースにも適用してみてください！