gorm(PostgreSQL)単体テスト (go-sqlmock)



## 概要

DBの単体テストを行う際に、実際のDBを準備するのではなく、
sql-mockを利用してモックを作成してDB操作のテストを実施する

ここではCreate, Get操作のみを対象とする

>[!warning!]
Go歴半年（あそび）です。
情報に誤りがある可能性があります

go-sqlmock

install
```
go get github.com/DATA-DOG/go-sqlmock
```


- DB: PostgreSQL
- ORM: gorm

## テスト対象コード

### domian/user.go

各種インターフェースも定義していますが、ここでは割愛

```go
type User struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	Password  string    `json:"password"`
}
```

### repository/repository.go

```go
type userRepository struct {
	db *gorm.DB
}

func NewUserReposiotry(db *gorm.DB) domain.UserRepository {
	return &userRepository{
		db: db,
	}
}

func (ur *userRepository) Create(ctx context.Context, user *domain.User) error {
	return ur.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Create(user).Error; err != nil {
			return fmt.Errorf("create error: %v", err)
		}
		return nil
	})
}

func (ur *userRepository) GetUser(ctx context.Context, id int) (*domain.User, error) {
	var user domain.User
	if err := ur.db.WithContext(ctx).Where("id = ?", id).Take(&user).Error; err != nil {
		return nil, err
	}
	return &user, nil
}

func (ur *userRepository) GetAllUser(ctx context.Context) ([]domain.User, error) {
	var users []domain.User
	if err := ur.db.WithContext(ctx).Find(&users).Error; err != nil {
		return nil, err
	}
	return users, nil
}

func (ur *userRepository) Delete(ctx context.Context, id int) error {
	return ur.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Where("id = ?", id).Delete(&domain.User{}).Error; err != nil {
			return fmt.Errorf("delete error: %v", err)
		}
		return nil
	})
}
```

## テスト対象コード

Table Driven Testを利用して、可読性、再利用性を意識して実装する

### repository/repository_test.go

mockの初期化を行うコードを共通化

```go
func getDBMock(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
	// SQLMockの初期化
	db, mock, err := sqlmock.New()
	require.NoError(t, err, "failed to create SQL mock")

	// GORMの初期化
	gdb, err := gorm.Open(postgres.New(postgres.Config{
		Conn: db,
	}), &gorm.Config{})
	require.NoError(t, err, "failed to open gorm DB connection")

	tearDown := func() {
		db.Close()
	}

	return gdb, mock, tearDown
}
```

### TestCreateUser

ポイントは`mock.ExpectQuery`を利用する点
go-sqlmockのREADMEには、INSERT INTOのテストを行う際は
`mock.ExpectExec`を利用するように記載されているが、PostgreSQLのCreateには戻り値があるため、`mock.ExpectQuery`を利用する

```go
func TestCreateUser(t *testing.T) {
	tests := []struct {
		title         string
		user          *domain.User
		query         string
		expectedError bool
	}{
		{
			"create a user successfully",
			&domain.User{
				Name:      "sample name",
				Email:     "test@test.co.jp",
				Password:  "secret",
			},
			`INSERT INTO "users" ("name","email","password") VALUES ($1,$2,$3)`,
			false,
		},
		{
			"create a user with error",
			&domain.User{
				Name:      "sample name",
				Email:     "test@test.co.jp",
				Password:  "secret",
			},
			`INSERT INTO "users" ("name","email","password") VALUES ($1,$2,$3)`,
			true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.title, func(t *testing.T) {
			// mock
			db, mock, teardown := getDBMock(t)
			defer teardown()
			mock.MatchExpectationsInOrder(false)
			mock.ExpectBegin()
			if tt.expectedError {
				mock.ExpectQuery(regexp.QuoteMeta(tt.query)).
					WithArgs(tt.user.Name, tt.user.Email, tt.user.Password, tt.user.CreatedAt).
					WillReturnError(fmt.Errorf("create user error"))
				mock.ExpectRollback()
			} else {
				mock.ExpectQuery(regexp.QuoteMeta(tt.query)).
					WithArgs(tt.user.Name, tt.user.Email, tt.user.Password).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)
				mock.ExpectCommit()
			}

			// run
			r := repository.NewUserReposiotry(db)
			err := r.Create(context.TODO(), tt.user)

			// assert
			if tt.expectedError {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}

			if err := mock.ExpectationsWereMet(); err != nil {
				t.Errorf("there were unfulfilled expectations: %s", err)
			}
		})
	}
}
```


### TestGetUser

```go
func TestGetUser(t *testing.T) {
	columns := []string{"id", "name", "email", "password"}

	tests := []struct {
		title         string
		id            int
		query         string
		mockRow       []driver.Value
		expected      *domain.User
		expectedError bool
	}{
		{
			"get a user successfully",
			1,
			`SELECT * FROM "users" WHERE id = $1 LIMIT $2`,
			[]driver.Value{1, "sample name", "test@test.co.jp", "secret", strToTime("2024-11-27 08:57:01")},
			&domain.User{
				ID:        1,
				Name:      "sample name",
				Email:     "test@test.co.jp",
				Password:  "secret",
			},
			false,
		},
		{
			"get a user with error",
			1,
			`SELECT * FROM "users" WHERE id = $1 LIMIT $2`,
			[]driver.Value{1, "sample name", "test@test.co.jp", "secret", strToTime("2024-11-27 08:57:01")},
			&domain.User{
				ID:        1,
				Name:      "sample name",
				Email:     "test@test.co.jp",
				Password:  "secret",
			},
			true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.title, func(t *testing.T) {
			// mock
			db, mock, teardown := getDBMock(t)
			defer teardown()
			if tt.expectedError {
				mock.ExpectQuery(regexp.QuoteMeta(tt.query)).
					WithArgs(tt.id, 1).
					WillReturnError(fmt.Errorf("get user error"))
			} else {
				rows := sqlmock.NewRows(columns).AddRow(tt.mockRow...)
				mock.ExpectQuery(regexp.QuoteMeta(tt.query)).
					WithArgs(tt.id, 1).
					WillReturnRows(rows)
			}

			// run
			r := repository.NewUserReposiotry(db)
			actual, err := r.GetUser(context.TODO(), tt.id)

			// assert
			if tt.expectedError {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
				require.Equal(t, tt.expected, actual)
			}

			if err := mock.ExpectationsWereMet(); err != nil {
				t.Errorf("there were unfulfilled expectations: %s", err)
			}
		})
	}
}
```


## 参考

[GitHub - DATA-DOG/go-sqlmock: Sql mock driver for golang to test database interactions](https://github.com/DATA-DOG/go-sqlmock)