【Golang】go-sqlmock でGorm を使ったアプリケーションのテストを書く

go-sqlmock を使ったことがなかったので、Gormを使ってデータベースにアクセスするアプリケーションをテストするコードを書いてみました。

go-sqlmock の READMEに

sqlmock is a mock library implementing sql/driver. Which has one and only purpose - to simulate any sql driver behavior in tests, without needing a real database connection. sqlmockはsql/driverを実装したモックライブラリです。目的は一つだけで、実際のデータベース接続を必要とせずに、テストで任意のsqlドライバの動作をシミュレートすることです。

という記載があります。なので、Go標準のdatabase/sql パッケージでSQLクエリを発行する場合でも、Gorm のような ORM フレームワークを使った場合でもDBを使わずにテストも行うことができます。

前提

ディレクトリ/ファイル構成と概要

以下のシンプルな構成のサンプルアプリケーションにテストを追加してみます。

.
├── main.go
├── model
│   └── book.go
└── repository
    ├── book.go
    └── book_test.go

それぞれのファイルでやっていることの概要は

  • main.go
    • リポジトリ(repository/book.go)を介して、書籍(book)のデータを登録。
  • model/book.go
    • 書籍(book)を表す構造体を定義。
  • repository/book.go
    • データベースにアクセスし、SQLを発行して書籍(book)のデータを登録。そして、結果を返す。
  • repository/book_test.go
    • 追加するrepository/book.goのテスト。ここでgo-sqlmockを使ってsqlドライバの動作をシミュレート。

です。

サンプルアプリケーションを動かしてみる

main.go

まずは main.go の内容をみてみます。単純に書籍(book)のcreateを行いエラーが発生しないか確認しているだけです。

package main

import (
    "fmt"

    "github.com/moritamori/gorm-testing/model"
    "github.com/moritamori/gorm-testing/repository"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    // DB接続を開く
    url := "dbname=gormtesting password=mypassword"
    db, err := gorm.Open(postgres.Open(url), &gorm.Config{})
    if err != nil {
        panic(err)
    }

    // リポジトリ(`repository/book.go`)を介して、書籍(book)のデータを登録
    bookRepository := repository.BookRepositoryImpl{DB: db}
    book := &model.Book{
        Title:  "Go言語の本",
        Author: "誰か",
    }
    err := bookRepository.Create(book)

    // エラーが発生しないかチェック
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("success!")
}

※ローカルで実行する際にはpostgresqlgormtestingデータベースとbooksテーブルが存在する必要があります。

model, repository (book.go)

書籍の構造体の定義( model )と実際のDBの処理( repository )を別のパッケージに分けたうえで、BookRepository というインタフェースを介して具体的なDBの処理は行う設計にしています。

こうすることで、パッケージの外から BookRepository を介してDB処理をする際、repositoryの中の実装を意識せずに済みます。

package model

import "gorm.io/gorm"

type Book struct {
    gorm.Model
    Title  string
    Author string
}
package repository

import (
    "gorm.io/gorm"
    "github.com/moritamori/gorm-testing/model"
)

type BookRepositoryImpl struct {
    DB *gorm.DB
}

type BookRepository interface {
    Create(book *model.Book) error
}

func (bookRepo BookRepositoryImpl) Create(book *model.Book) error {
    cx := bookRepo.DB.Create(book)
    return cx.Error
}

実行

実行すると、想定通り動いていることを確認できます。

$ go run main.go
success!

テストを追加

サンプルアプリケーションにテストを追加しました。テストのセットアップ、データベース接続のクローズテストを共通化するために testify を使っています。

package repository

import (
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/moritamori/gorm-testing/model"
    "github.com/stretchr/testify/suite"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

// テストスイートの構造体
type BookRepositoryTestSuite struct {
    suite.Suite
    bookRepository BookRepositoryImpl
    mock           sqlmock.Sqlmock
}

// テストのセットアップ
// (sqlmockをNew、Gormで発行されるクエリがモックに送られるように)
func (suite *BookRepositoryTestSuite) SetupTest() {
    db, mock, _ := sqlmock.New()
    suite.mock = mock
    bookRepository := BookRepositoryImpl{}
    bookRepository.DB, _ = gorm.Open(postgres.New(postgres.Config{
        Conn: db,
    }), &gorm.Config{})
    suite.bookRepository = bookRepository
}

// テスト終了時の処理(データベース接続のクローズ)
func (suite *BookRepositoryTestSuite) TearDownTest() {
    db, _ := suite.bookRepository.DB.DB()
    db.Close()
}

// テストスイートの実行
func TestBookRepositoryTestSuite(t *testing.T) {
    suite.Run(t, new(BookRepositoryTestSuite))
}

// Createのテスト
func (suite *BookRepositoryTestSuite) TestCreate() {
    suite.Run("create a book", func() {
        newId := 1
        rows := sqlmock.NewRows([]string{"id"}).AddRow(newId)
        suite.mock.ExpectBegin()
        suite.mock.ExpectQuery(
            regexp.QuoteMeta(
                `INSERT INTO "books" ("created_at",` +
                    `"updated_at","deleted_at","title",` +
                    `"author") VALUES ($1,$2,$3,$4,$5) ` +
                    `RETURNING "id"`),
        ).WillReturnRows(rows)
        suite.mock.ExpectCommit()
        book := &model.Book{
            Title:  "Go言語の本",
            Author: "誰か",
        }
        err := suite.bookRepository.Create(book)

        if err != nil {
            suite.Fail("Error発生")
        }
        if book.ID != uint(newId) {
            suite.Fail("登録されるべきIDと異なっている")
        }
    })
}

testify を使う利点

このように testify を使うことでテストケースが増えた際、共通の関心事であるテストのセットアップ・終了時の処理のコードが重複しないテストコードを書けるというメリットがあります。

Gorm を使う際の考慮

トランザクションの考慮

Gormcreate/update/delete に関してはデフォルトでトランザクションが有効になるため、トランザクションのテスト(suite.mock.ExpectBegin()suite.mock.ExpectCommit)を書いています。

ExpectQueryExpectExec

また、 postgresql と Gormを一緒に使った注意点がこちらの記事に記載があって、通常Insert文は ExpectExec でクエリをチェックしますが postgresqlを使用する場合ExpectQuery でクエリをチェックしています。

In general, a non-query SQL expectation (e.g Insert/Update) should be defined by mock.ExpectExec, but this is a special case. For some reason, GROM uses QueryRow instead of Exec for thepostgres dialect (for more detail, please consult this issue).

Tip: Use mock.ExpectQuery for the GORM model insert if you’re using PostgreSQL.

という記載があり、参照先のGithub issueにも同様のコメントがあります。

Gorm V2におけるInsertの挙動

上記のissueが古く、Gorm v1だけに該当する話の可能性があったので、念の為Gorm v2のソースコードを用いてcreateするメソッド(CreateWithReturning)デバッグ実行して確認したところ、テーブルスキーマにデフォルト値がある場合QueryContextを実行していたので、ExpectQuery でクエリを指定するとマッチしそうです。

また、どのカラムにデフォルト値が付くか確認したところ、booksテーブルのgorm.Model追加されるIDカラムにデフォルト値が付いていたので、gorm.Model を使っていればExpectQuery で確認するのが良さそうです。

sqlmockを使う利点

sqlmockはデフォルトで期待する結果を厳密な順序で得られることを確認してくれるので、このサンプルでは

  • suite.mock.ExpectBegin()
  • suite.mock.ExpectQuery(regexp.QuoteMeta(INSERT INTO "books" 〜))
  • suite.mock.ExpectCommit()

という順番でモックに送られることを期待します。

テスト実行

実行すると、OKが返ります。

$ go test ./... -v
?       github.com/moritamori/gorm-testing  [no test files]
?       github.com/moritamori/gorm-testing/model    [no test files]
=== RUN   TestBookRepositoryTestSuite
=== RUN   TestBookRepositoryTestSuite/TestCreate
=== RUN   TestBookRepositoryTestSuite/TestCreate/create_a_book
--- PASS: TestBookRepositoryTestSuite (0.00s)
    --- PASS: TestBookRepositoryTestSuite/TestCreate (0.00s)
        --- PASS: TestBookRepositoryTestSuite/TestCreate/create_a_book (0.00s)
PASS
ok      github.com/moritamori/gorm-testing/repository   0.079s

今回のサンプルアプリケーション

コードをGithubにあげているので、もしよかったら参考にしてみてください! github.com

参考資料