はじめに
この記事では、Go言語を使ったモックテストを徹底解説します。特にデータベース操作のエラーハンドリングに焦点を当て、sqlmockライブラリを活用して、Newspaperモデルの動作をどのようにテストするかを具体例を交えて詳しく解説します。
記事の構成
- 
なぜモックが必要なのか?
- 実際のデータベースとモックデータベースの違い
 
- 
testerパッケージの実装- モックデータベースの作成方法
- サポート関数やユーティリティの解説
 
- 
Newspaperモデルのモックテスト実装- テストスイートの作成
- エラーケースをシミュレーションする手法
 
- 
コード一行ずつの解説
- 各セクションの処理内容を詳細に説明
 
- 
テスト結果と考察
- 実行例と得られる知見
 
1. なぜモックが必要なのか?
テストコードを書く際に、実際のデータベースを利用すると以下の課題があります:
- 速度の問題: 実際のデータベースへのアクセスは遅い。
- データ汚染: テストによって実際のデータが書き換えられる危険がある。
- 再現性の難しさ: データベースの状態に依存するため、テストの結果が一貫しないことがある。
モックデータベースを使えば、これらの問題を解消できます。sqlmockは、SQLクエリをモックするためのライブラリで、特定のクエリやエラーをシミュレーションでき、再現性が高いテスト環境を構築するのに最適です。
2. testerパッケージの実装
まずは、testerパッケージでモックデータベースを作成するコードを見てみましょう。
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  
}  
このセクションのポイント:
- 
MockDB関数:
 SQLモックとGORMを組み合わせて、データベース操作を完全にモック化。これにより、外部データベースに依存せずに動作をテスト可能。
- 
mockClock構造体:
 特定の時刻をテスト時に固定するために使用。時間依存のロジックがある場合に役立つ。
3. Newspaperモデルのモックテスト実装
ここでは、実際にNewspaperモデルのCRUD操作をテストするコードを紹介します。特に、失敗ケース(エラーハンドリング)を中心に解説します。
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  
}  
各テストケースの目的と動作
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
このテストは、記事を新規作成する際にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。
- 
mockDBの設定 
 mockDB := suite.MockDB()でモックデータベースを初期化します。
- 
ExpectBeginの設定 
 モックがトランザクションの開始を期待することを設定します。
- 
ExpectExecの設定 
 INSERT INTOクエリを期待し、指定した引数("Test"と"sports")が渡される場合にエラー"create error"を返すように設定します。
- 
ExpectRollbackの設定 
 クエリが失敗した際にトランザクションがロールバックされることを期待します。
- 
CreateNewspaperの実行 
 models.CreateNewspaper("Test", "sports")を呼び出します。
- 
アサーション - 作成された記事がnilであることを確認。
- エラーが発生したことを確認。
- エラーメッセージが"create error"であることを確認。
 
- 作成された記事が
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
記事取得時にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。
- 
mockDBの設定 
 mockDB := suite.MockDB()でモックデータベースを初期化します。
- 
ExpectQueryの設定 
 SELECT * FROMクエリを期待し、引数1が渡される場合にエラー"get error"を返すよう設定します。
- 
GetNewspaperの実行 
 models.GetNewspaper(1)を呼び出します。
- 
アサーション - 取得された記事がnilであることを確認。
- エラーが発生したことを確認。
- エラーメッセージが"get error"であることを確認。
 
- 取得された記事が
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
記事の保存時にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。
- 
mockDBの設定 
 mockDB := suite.MockDB()でモックデータベースを初期化します。
- 
ExpectBeginの設定 
 トランザクションの開始を期待します。
- 
ExpectExecの設定 
 UPDATEクエリを期待し、引数("updated", "sports", 1)が渡される場合にエラー"update error"を返すように設定します。
- 
ExpectRollbackの設定 
 クエリが失敗した際にトランザクションがロールバックされることを期待します。
- 
Saveの実行 
 新聞記事のタイトルを変更後にnewspaper.Save()を呼び出します。
- 
アサーション - エラーが発生したことを確認。
- エラーメッセージが"update error"であることを確認。
 
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
記事削除時にエラーが発生するケースをシミュレーションし、適切にエラーが処理されるかを確認します。
- 
mockDBの設定 
 mockDB := suite.MockDB()でモックデータベースを初期化します。
- 
ExpectBeginの設定 
 トランザクションの開始を期待します。
- 
ExpectExecの設定 
 DELETEクエリを期待し、引数0が渡される場合にエラー"delete error"を返すように設定します。
- 
ExpectRollbackの設定 
 クエリが失敗した際にトランザクションがロールバックされることを期待します。
- 
Deleteの実行 
 新聞記事を削除するnewspaper.Delete()を呼び出します。
- 
アサーション - エラーが発生したことを確認。
- エラーメッセージが"delete error"であることを確認。
 
4. テスト結果と考察
テストを実行すると、以下の結果が得られます:
- データベース操作がモックデータベースで正しく動作する。
- 期待されるエラーメッセージが返る。
- モックを活用することで外部依存を排除し、テストの再現性が確保される。
まとめ
これで、モックを利用したNewspaperモデルのテストが完成しました。この方法を参考に、他のモデルやユースケースにも適用してみてください!