5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RCC (立命館コンピュータークラブ)Advent Calendar 2024

Day 7

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

Last updated at Posted at 2024-12-07

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

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

GORMについて

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

ORM(Object Relational Mapper)とは

リレーショナルデータベース操作をオブジェクト指向の考えでできるようにする技術です。

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

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

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

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

dockerとか使用している場合は、下のようにテスト用のデータベースを定義します。

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

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

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
}

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

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

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

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

3. SQLのモックを使う

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

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

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

次のコマンドでgo modに依存関係を追加します。

zsh
go get github.com/DATA-DOG/go-sqlmock

実際に使ってみる

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

sqlmockのインスタンスを生成

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

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
sqlDB, mock, err := sqlmock.New()

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

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

SkipInitializeWithVersionについて

SkipInitializeWithVersionは必ずつけてください。

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

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

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するケース

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

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

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
}

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

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

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

トランザクションとは

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

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

なぜかわかりませんが下のようなエラーが出たため、トランザクションを使っています。

zsh
Error: Received unexpected error:
        call to database transaction Begin, was not expected

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

メタ文字とは

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

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

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

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

テストの実行は次のコードで行います。

zsh
go test -v ./...

./...とは

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

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

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

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
}

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

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
=== 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
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
SELECT * FROM `todos` WHERE id = ?

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

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

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

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

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

下のようにコンソールに表示されます。

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が論理的にデータを扱うように設計されているためにこのようなエラーが起きていました。

物理削除と論理削除

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

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

Go関連の記事

5
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?