gormでsqlのモックを作って単体テストを行う方法[Go&MySQL]

データベースからデータを取り出す単一操作をrepositoryとか名前をつけて実装した時に、どうやってテストを行えばいいんだろうって思ったことありませんか?

今回は、そんな時に使えるGoのsqkmockパッケージ`go-sqlmock`を紹介したいと思います。

https://github.com/DATA-DOG/go-sqlmock

# GORMについて

GORMは、GoのORMパッケージです。

:::note
### ORM(Object Relational Mapper)とは
リレーショナルデータベース操作をオブジェクト指向の考えでできるようにする技術です。
:::

詳しくは下の記事をご覧ください。

https://qiita.com/maooz4426/items/91fa49341bc2e9d8eddd


# そもそもデータベース依存のテストを行う方法

### 1.テスト用のデータベースを作る

テストをする方法として、test用のデータベースを作る方法があります。

dockerとか使用している場合は、下のようにテスト用のデータベースを定義します。
```yml:docker-compose.yml
  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
```

そこから接続操作を下のように記述します。

```go:conn.go
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
}
```

:::note
IPアドレスがコンテナ名になっているのは、`docker compose`使用時にブリッジネットワークを作成して、dockerがコンテナ名でIPアドレスを解決してくれるためです。
:::

そして、実装したコードには実行後にテーブルをクリアするようなクリーンアップ機能を実装しておけば、テストをすることができます。

### 2.トランザクションを使う

トランザクションを使うことで、実行した後にロールバックを行い、データベースに反映させないという方法もあります。

### 3. SQLのモックを使う

今回、紹介するのはこちらの方法です。

インメモリに擬似的なsqlを再現することで、実際にはデータベースに入れていないですが、コードは実際にデータベースにデータを挿入するような動きをするので、データベースを荒らさずにテストをすることができます。

# パッケージをインストールする

次のコマンドで`go mod`に依存関係を追加します。
```go:zsh
go get github.com/DATA-DOG/go-sqlmock
```

# 実際に使ってみる

`go-sqlmock`によるメモリを使った仮想的なデータベースを使ってテストしてみようと思います。

## sqlmockのインスタンスを生成

まず、sqlのモックのインスタンスを生成するコードを書いていこうと思います。

```go:conn.go
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
}
```

まず、次にコードでデータベースのインスタンス、カラム等を設定するモックを生成します。

```go:go
sqlDB, mock, err := sqlmock.New()
```

次にgormの接続操作を書いています。

```go:go
mockDB, err := gorm.Open(mysql.New(mysql.Config{
		Conn:                      sqlDB,
		SkipInitializeWithVersion: true,
	}), &gorm.Config{})
```

:::note
### `SkipInitializeWithVersion`について
`SkipInitializeWithVersion`は必ずつけてください。

これは、GORMがよしなにデータベースのバージョンチェックとそれに合わせた動作をしてくれるオプションです。

こちらをtrueにしておかないと下のようなエラーが出ます。

```zsh:zsh
[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するケース

まず挿入操作のテストを書いてみようと思います。

テストするケースは下のコードとなっています。

```go:todo.go
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
}
```

これに対してテストを書いていきます。

実際に書いたテストコードは下のようになります。

```go:todo_test.go
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()`はトランザクションを実行しています。

:::note
### トランザクションとは

一連のデータベース操作が成功したら、データベースに反映、失敗したら元の状態に戻すようにする技術のことを指します。

:::

:::note

### トランザクションしている理由

なぜかわかりませんが下のようなエラーが出たため、トランザクションを使っています。
```zsh:zsh
Error: Received unexpected error:
        call to database transaction Begin, was not expected
```

:::

`ExpectExec()`は実行されるクエリを指定しています。
クエリで使用されている`regexp.QuoteMeta()`は、よしなにメタ文字をエスケープしてくれます。

:::note
### メタ文字とは

正規表現で特殊な意味を持つ非アルファベット文字のことを指します。

メタ文字をエスケープするというのは、特殊な意味を持つ言葉として使用できるようにすることを指します。
:::

`WithArgs()`で?(プレースホルダー)に入れる引数を指定しています。
`sqlmock.AnyArg()`を入れると値をよしなに決定してくれます。

`WillReturnResult()`は、予測される実行された後の結果を示しています。
`sqlmock.NewResult()`は、行に関して予測されるものを指定します。第一引数に挿入された行(LastInsertID)、第二引数に影響を受けた行(RowsAffecteID)を指定します。

テストの実行は次のコードで行います。
```zsh:zsh
go test -v ./...
```

:::note
### ./...とは
現在のディレクトリ以下にあるすべてのパッケージを再帰的に指定することができます。
今回で言うと現在のディレクトリ以下にある全てのテストコード(ファイル名に`_test`がついてる)を指定することができます。
:::

実行できると下のようになります。

```zsh:zsh
=== 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で探す単一データベース操作におけるケースのテストを記述しようと思います。

```go:todo.go
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
}
```

これを元にテストケースを記述しようと思います。

```go:todo_test.go
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()`は、返される行を定義しています。

実行できたら下のようになります。

```zsh:zsh
=== RUN   TestFindById
--- PASS: TestFindById (0.00s)
```

# まとめ

`sqlmock`で使用したメソッド等をまとめてみたいと思います。

|メソッド等|機能
|---|---
|mock.ExpectExec()|実行されるクエリを定義する
|mock.ExpectBegin()|トランザクションを実行する
|mock.ExpectCommit()|トランザクションをコミットする
|mock.WithArgs()|プレースホルダーに入れる引数を指定する
|sqlmock.AnyArg()|よしなに値を決める
|WillReturnResult()|実行結果を定義する
|sqlmock.NewResult()|実行結果における行に関する結果を定義する
|sqlmock.NewRows()|カラムを定義します。
|AddRows()|挿入されている。
|WillReturnRows()|実行されて返される列を定義します。

# おまけ

実装している際に下のようなエラーが出ると思います。

```zsh:zsh
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文で行っていると思っていたことが

```sql:sql
SELECT * FROM `todos` WHERE id = ?
```

実際は、gormが行っている操作はそれに加えてtodosテーブルの`deleted_at`カラムがNULLであるという操作が追加されていました。

```sql:sql
SELECT * FROM `todos` WHERE id = '1' AND `todos`.`deleted_at` IS NULL
```

:::note
### gormの行われているクエリを確認する方法

`Debug()`をつけることで確認することができます。

```go:go
result := m.db.Debug().Where("id = ?", id).Find(&todo)
```

下のようにコンソールに表示されます。
```zsh:zsh
2024/10/21 05:04:00 /app/infrastructure/mysql/repository/todo.go:49
[0.090ms] [rows:1] SELECT * FROM `todos` WHERE id = '1' AND `todos`.`deleted_at` IS NULL
```
:::

これはGORMが論理的にデータを扱うように設計されているためにこのようなエラーが起きていました。

:::note
### 物理削除と論理削除

物理削除はデータベースからデータそのものを消してしまうことを指します。

一方で論理削除は`deleted at`というレコードに値を入れることで、アプリケーション上ではレコードは消されているように扱う削除方法のことを指します。こうすることで、`delted at`をNULLにするだけでデータの復元が可能になります。

:::

# Go関連の記事

https://qiita.com/maooz4426/items/f6740347eec6df320a1e

https://qiita.com/maooz4426/items/2f9fe5e8d6b6a44b3d57

https://qiita.com/maooz4426/items/422a8d6428c2504efe23

https://qiita.com/maooz4426/items/63071aba6a69c9a55ef4

https://qiita.com/maooz4426/items/1ab385f3aceeb660e500

https://qiita.com/maooz4426/items/592028f297ca76830c6b

https://qiita.com/maooz4426/items/44a71129ce2f0a463817