データベースからデータを取り出す単一操作をrepositoryとか名前をつけて実装した時に、どうやってテストを行えばいいんだろうって思ったことありませんか?
今回は、そんな時に使えるGoのsqkmockパッケージgo-sqlmock
を紹介したいと思います。
GORMについて
GORMは、GoのORMパッケージです。
詳しくは下の記事をご覧ください。
そもそもデータベース依存のテストを行う方法
1.テスト用のデータベースを作る
テストをする方法として、test用のデータベースを作る方法があります。
dockerとか使用している場合は、下のようにテスト用のデータベースを定義します。
test_db:
image: mysql:8.0
container_name: todo_test_db
restart: always
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
MYSQL_USER: test_user
MYSQL_PASSWORD: test_password
TZ: Asia/Tokyo
LANG: ja_JP.UTF-8
ports:
- 3307:3306
volumes:
- test-db-volume:/var/lib/mysql
そこから接続操作を下のように記述します。
func ConnectDBTest() (*gorm.DB, error) {
var db *gorm.DB
var err error
dsn := "test_user:test_password@tcp(todo_test_db:3306)/test_db?charset=utf8mb4&parseTime=True"
count := 5
for count > 1 {
if db, err = gorm.Open(mysql.Open(dsn)); err != nil {
time.Sleep(2 * time.Second)
count--
log.Printf("retry... count:%v\n", count)
continue
}
break
}
err = db.AutoMigrate(&entity.Todo{})
if err != nil {
return nil, err
}
return db, nil
}
IPアドレスがコンテナ名になっているのは、docker compose
使用時にブリッジネットワークを作成して、dockerがコンテナ名でIPアドレスを解決してくれるためです。
そして、実装したコードには実行後にテーブルをクリアするようなクリーンアップ機能を実装しておけば、テストをすることができます。
2.トランザクションを使う
トランザクションを使うことで、実行した後にロールバックを行い、データベースに反映させないという方法もあります。
3. SQLのモックを使う
今回、紹介するのはこちらの方法です。
インメモリに擬似的なsqlを再現することで、実際にはデータベースに入れていないですが、コードは実際にデータベースにデータを挿入するような動きをするので、データベースを荒らさずにテストをすることができます。
パッケージをインストールする
次のコマンドでgo mod
に依存関係を追加します。
go get github.com/DATA-DOG/go-sqlmock
実際に使ってみる
go-sqlmock
によるメモリを使った仮想的なデータベースを使ってテストしてみようと思います。
sqlmockのインスタンスを生成
まず、sqlのモックのインスタンスを生成するコードを書いていこうと思います。
func NewDbMock() (*gorm.DB, sqlmock.Sqlmock, error) {
sqlDB, mock, err := sqlmock.New()
mockDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
return mockDB, mock, err
}
まず、次にコードでデータベースのインスタンス、カラム等を設定するモックを生成します。
sqlDB, mock, err := sqlmock.New()
次にgormの接続操作を書いています。
mockDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
SkipInitializeWithVersion
について
SkipInitializeWithVersion
は必ずつけてください。
これは、GORMがよしなにデータベースのバージョンチェックとそれに合わせた動作をしてくれるオプションです。
こちらをtrueにしておかないと下のようなエラーが出ます。
[error] failed to initialize database, got error all expectations were already fulfilled, call to Query 'SELECT VERSION()' with args [] was not expected
参考:https://github.com/go-gorm/gorm/issues/3565#issuecomment-712113474
ケースに合わせてテストを書いてみる
Insertするケース
まず挿入操作のテストを書いてみようと思います。
テストするケースは下のコードとなっています。
func (m *TodoRepository) Insert(ctx context.Context, task *entity.Todo) (*entity.Todo, error) {
todo := entity.Todo{
Task: task.Task,
Done: false,
Deadline: task.Deadline,
}
result := m.db.Create(&todo)
if result.Error != nil {
return &entity.Todo{}, result.Error
}
return &todo, nil
}
これに対してテストを書いていきます。
実際に書いたテストコードは下のようになります。
func TestInsert(t *testing.T) {
sqlDB, mock, err := mysql.NewDbMock()
require.NoError(t, err)
ctx := context.Background()
taskName := "test"
done := false
deadline, err := time.Parse("2006-01-02", "2024-10-11")
require.NoError(t, err)
var task *entity.Todo = &entity.Todo{Model: gorm.Model{ID: uint(1)}, Task: taskName, Done: done, Deadline: deadline}
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(
"INSERT INTO `todos` (`created_at`,`updated_at`,`deleted_at`,`task`,`done`,`deadline`) VALUES (?,?,?,?,?,?)")).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), nil, taskName, done, deadline).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
m := NewTodoRepository(sqlDB)
taskRes, err := m.Insert(ctx, task)
require.NoError(t, err)
require.Equal(t, uint(1), taskRes.ID)
require.Equal(t, "test", task.Task)
require.Equal(t, deadline, taskRes.Deadline)
}
使っているメソッドについて解説しようと思います。
ExpectBegin()
とExpectCommit()
はトランザクションを実行しています。
ExpectExec()
は実行されるクエリを指定しています。
クエリで使用されているregexp.QuoteMeta()
は、よしなにメタ文字をエスケープしてくれます。
WithArgs()
で?(プレースホルダー)に入れる引数を指定しています。
sqlmock.AnyArg()
を入れると値をよしなに決定してくれます。
WillReturnResult()
は、予測される実行された後の結果を示しています。
sqlmock.NewResult()
は、行に関して予測されるものを指定します。第一引数に挿入された行(LastInsertID)、第二引数に影響を受けた行(RowsAffecteID)を指定します。
テストの実行は次のコードで行います。
go test -v ./...
実行できると下のようになります。
=== RUN TestInsert
2024/10/21 13:54:50 /app/infrastructure/mysql/repository/todo.go:25
[0.107ms] [rows:1] INSERT INTO `todos` (`created_at`,`updated_at`,`deleted_at`,`task`,`done`,`deadline`) VALUES ('2024-10-21 13:54:50.795','2024-10-21 13:54:50.795',NULL,'test',false,'2024-10-11 00:00:00')
--- PASS: TestInsert (0.00s)
SELECTのケース
IDで探す単一データベース操作におけるケースのテストを記述しようと思います。
func (m *TodoRepository) FindById(ctx context.Context, id string) (*entity.Todo, error) {
var todo entity.Todo
result := m.db.Where("id = ?", id).Find(&todo)
if result.Error != nil {
return &entity.Todo{}, result.Error
}
return &todo, nil
}
これを元にテストケースを記述しようと思います。
func TestFindById(t *testing.T) {
sqlDB, mock, err := mysql.NewDbMock()
require.NoError(t, err)
taskName := "test"
done := false
deadline, _ := time.Parse("2006-01-02", "2024-10-11")
rows := sqlmock.NewRows([]string{"id", "task", "done", "deadline", "created_at", "updated_at", "deleted_at"}).
AddRow(1, taskName, done, deadline, time.Now(), time.Now(), nil)
mock.ExpectQuery(regexp.QuoteMeta(
"SELECT * FROM `todos` WHERE id = ? AND `todos`.`deleted_at` IS NULL")).
WithArgs("1").
WillReturnRows(rows)
m := NewTodoRepository(sqlDB)
ctx := context.Background()
foundTask, err := m.FindById(ctx, "1")
require.NoError(t, err)
require.Equal(t, uint(1), foundTask.ID)
require.Equal(t, taskName, foundTask.Task)
require.Equal(t, done, foundTask.Done)
require.Equal(t, deadline, foundTask.Deadline)
}
こちらもほとんどさっきのINSERTでのケースと書き方はあまり変わらないです。
先ほどとは違いトランザクションのエラーが出てないので、
mock.ExecBegin()
は書いてないです。
sqlmock.NewRows()
は、カラムを定義しています。
その後で使っているAddRows()
で挿入されているデータを定義しています。
WillReturnRows()
は、返される行を定義しています。
実行できたら下のようになります。
=== RUN TestFindById
--- PASS: TestFindById (0.00s)
まとめ
sqlmock
で使用したメソッド等をまとめてみたいと思います。
メソッド等 | 機能 |
---|---|
mock.ExpectExec() | 実行されるクエリを定義する |
mock.ExpectBegin() | トランザクションを実行する |
mock.ExpectCommit() | トランザクションをコミットする |
mock.WithArgs() | プレースホルダーに入れる引数を指定する |
sqlmock.AnyArg() | よしなに値を決める |
WillReturnResult() | 実行結果を定義する |
sqlmock.NewResult() | 実行結果における行に関する結果を定義する |
sqlmock.NewRows() | カラムを定義します。 |
AddRows() | 挿入されている。 |
WillReturnRows() | 実行されて返される列を定義します。 |
おまけ
実装している際に下のようなエラーが出ると思います。
2024/10/20 15:04:52 /app/infrastructure/mysql/repository/todo.go:49 Query 'SELECT * FROM `todos` WHERE id = ? AND `todos`.`deleted_at` IS NULL', arguments do not match: expected 2, but got 1 arguments
[0.089ms] [rows:0] SELECT * FROM `todos` WHERE id = '1' AND `todos`.`deleted_at` IS NULL
todo_test.go:114:
Error Trace: /app/infrastructure/mysql/repository/todo_test.go:114
Error: Received unexpected error:
Query 'SELECT * FROM `todos` WHERE id = ? AND `todos`.`deleted_at` IS NULL', arguments do not match: expected 2, but got 1 arguments
Test: TestFindById
--- FAIL: TestFindById (0.00s)
これは、私たちが以下のSELECT文で行っていると思っていたことが
SELECT * FROM `todos` WHERE id = ?
実際は、gormが行っている操作はそれに加えてtodosテーブルのdeleted_at
カラムがNULLであるという操作が追加されていました。
SELECT * FROM `todos` WHERE id = '1' AND `todos`.`deleted_at` IS NULL
これはGORMが論理的にデータを扱うように設計されているためにこのようなエラーが起きていました。
Go関連の記事