Goを使ったSQLテストの実現方法について書きました。

もくじ

 

 

 

 

 

 

 

 

 

はじめに

こんにちは。

EMoshUインターン生のしゅうです!

業務として自社アプリのバックエンドをGoを使って開発しています!

 

今回は、SQLの単体テストをGoを使ってどのように実現するのか調べてみました。

実際にそれぞれのパターンを実装してみて考察していきます。

是非最後までお付き合いください!

 

 

 

 

SQLテストパターン

みなさんはSQL周りのテストを書くとき、どのような方法で行っていますか?

個人的には以下の2つの方法が簡単で王道かなと考えています。

 

  • DBのモックを立てる
  • コンテナで実際のDBを立てる

 

これらの手法でテストを行う場合、Goではどのように実現すればよいのでしょうか?

 

今回は前者を DATA-DOG/go-sqlmock、後者を ory/dockertest を使って実現していきます。

 

 

 

 

テスト用プロジェクト

 

今回はテスト用のプロジェクトとしてシンプルなAPIを用意しました。

DBはMySQLを使用し、DBの接続にGo製のORマッパーであるGormを使っています。

 

go_simple_api
├── controller
│   └── user_controller.go
├── go.mod
├── go.sum
├── main.go
└── model
    ├── db.go
    └── user.go

 

SQLが絡んでいる model/ のコードを見てみましょう

 

model/db.go

package model

import ( 
    "os"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func InitDB() *gorm.DB { 

    dsn := os.Getenv("DB_DSN")
    var err error
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil { 
        panic("failed to connect database")
    }
    return db
}

 

ここではDB接続して*gorm.DBを初期化しています。

 

model/user.go

package model

import "gorm.io/gorm"

type User struct {
    ID        int    `json:"id"`
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
}

type UserModel struct {
    db *gorm.DB
}

func NewUserModel(db *gorm.DB) *UserModel {
    return &UserModel{db}
}

func (m *UserModel) FindUserById(id int) (user User, err error) {
    err = m.db.Find(&user, id).Error
    return
}

 

ここではusersテーブルに対してidからuserを取得するメソッドを実装しています。

UserModelが持つ*gorm.DBを使ってDBへアクセスしています。

 

それでは実際にテストを実装していきましょう。

 

 

 

 

go-sqlmock

 

go-sqlmockとは、Goのライブラリで簡単に sql/driver を実装したモックを立てることができます。

 

また、実際に発行されるSQLをテストすることができます。

 

早速実装してみましょう。

 

新たに model/db_test.go と model/user_mock_test.go を追加して以下のようなディレクトリ構成になりました。

 

go_simple_api
├── controller
│   └── user_controller.go
├── go.mod
├── go.sum
├── main.go
└── model
    ├── db.go
    ├── db_test.go
    ├── user.go
    └── user_mock_test.go

 

SQLドライバのモックを初期化

 

まずは、SQLドライバのモックを初期化してみます。

model/db_test.go に処理を書いています。

 

model/db_test.go

package model                                                                                                                           
                                                                                                                                        
import (                                                                                                                                
    "github.com/DATA-DOG/go-sqlmock"                                                                                                    
    _ "github.com/go-sql-driver/mysql"                                                                                                  
    "gorm.io/driver/mysql"                                                                                                              
    "gorm.io/gorm"                                                                                                                      
)                                                                                                                                       
                                                                                                                                        
func InitDBMock() (*gorm.DB, sqlmock.Sqlmock, error) {
    // sqlmockを初期化                                                                           
    mockDB, mock, err := sqlmock.New()

    if err != nil {
        return nil, mock, err
    }

    // DBのコネクションを初期化
    db, err := gorm.Open(mysql.New(
        mysql.Config{
            Conn:                      mockDB,
            SkipInitializeWithVersion: true,
        }),
        &gorm.Config{},
    )

    return db, mock, err
}

 

ここではモック用の*gorm.DBを初期化しています。

sqlmock.New()の定義は以下のようになっています。

 


func New(options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)

 

sqlmock.New()によって*sql.DBとSqlmockを初期化することができます。

基本的にはSqlmockを使ってDBの振る舞いなどを設定します。

引数にoptionを渡すことで後述するQueryMatcherを独自に設定できます。

 

今回はGormを利用しているので、sqlmock.New()で初期化した*sql.DBをコネクションとして

gorm.Open()によって*gorm.DBを初期化しています。

db_test.goのように_testをサフィックスに持つファイルに定義することでビルドされず、テスト時のみ利用可能になります。

 

テストコード

 

それでは実際のテストコードを見てみましょう。

 

model/user_mock_test.go

package model_test

import (
    "go_simple_api/model"
    "regexp"
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/stretchr/testify/assert"
)

func TestFindByIdMock(t *testing.T) {
    //MockDBを初期化する
    mockDB, mock, err := model.InitDBMock()

    if err != nil {
        t.Fatal(err)
    }

    //UserModelの初期化する
    userModel := model.NewUserModel(mockDB)

    //期待されるカラム
    expectedColumns := []string{
        "id",
        "first_name",
        "last_name",
    }

    //期待されるuser
    expect := model.User{
        ID:        1,
        FirstName: "Michael",
        LastName:  "Jordan",
    }

    //期待されるSQL
    query := "SELECT * FROM `users` WHERE `users`.`id` = ?"

    //DBの振る舞いを定義する
    mock.ExpectQuery(regexp.QuoteMeta(query)).
        WithArgs(1).
        WillReturnRows(sqlmock.NewRows(expectedColumns).AddRow(expect.ID, expect.FirstName, expect.LastName))

    s, _ := userModel.FindById(1)

    //実際のuserと期待されるuserを比較する
    assert.Equal(t, expect, s)
}

 

FindById()のテストを実装してみました。

 

上述したInitDbMock()で初期化したSqlmockに対して

ExpectQuery()で期待されるクエリを定義します。

sqlmockはデフォルトで正規表現を設定するので、regexp.QuoteMeta()でメタ文字をエスケープしています。

 

そして、WithArgs()でクエリの引数を設定します。

 

さらに、ここでは1件のレコードを取得するという挙動をテストしたいため

WillReturnRows()で期待されるカラムとレコードを設定しています。

 

ほかにも、トランザクションをテストするための ExpectedBegin, ExpectedCommit, ExpectedRollback や

insertやdeleteなどのレコードを返さない操作をテストするための ExpectedExec など、

ほぼすべての操作に対してテストするメソッドが用意されています。

 

QueryMatcher

 

QueryMathcerは期待する文字列と実際の文字列との比較の条件を定義します。

QueryMatcherの定義は以下のようなinterfaceとなっています。

 

type QueryMatcher interface {
    Match(expectedSQL, actualSQL string) error
}

 

Match()をメソッドに持つ構造体を定義すれば独自にQueryMathcerを定義できます。

標準では、QueryMatcherEqualとQueryMatcherRegexpという2つのQueryMathcerが用意されています。

 

QueryMatcherEqual は期待するクエリと実際のクエリが等しい場合のみテストが通ります。

 

QueryMatcherRegexp は期待するクエリを正規表現にパースして実際のクエリと等しい場合にテストが通ります。

 

デフォルトでは QueryMatcherRegexp が設定されています。

QueryMatcherEqual を設定したい場合は、以下のようにsqlmockの初期化時に設定します。

 

model/db_test.go

package model                                                                                                                           
                                                                                                                                        
import (                                                                                                                                
    "github.com/DATA-DOG/go-sqlmock"                                                                                                    
    _ "github.com/go-sql-driver/mysql"                                                                                                  
    "gorm.io/driver/mysql"                                                                                                              
    "gorm.io/gorm"                                                                                                                      
)                                                                                                                                       
                                                                                                                                        
func InitDBMock() (*gorm.DB, sqlmock.Sqlmock, error) {
    // sqlmockを初期化
    // QueryMatcherを設定                                                                      
    mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)))

    if err != nil {
        return nil, mock, err
    }

    // DBのコネクションを初期化
    db, err := gorm.Open(mysql.New(
        mysql.Config{
            Conn:                      mockDB,
            SkipInitializeWithVersion: true,
        }),
        &gorm.Config{},
    )

    return db, mock, err
}

 

 

 

 

 

dockertest

 

dockertest は簡単にdockerコンテナを立ち上げることができるライブラリです。

 

dockertestを使えば実際のDBに接続してテストを行うことができます。

 

実装してみましょう。

新たに model/user_docker_test.go を追加して以下のようなディレクトリ構成になりました。

 

go_simple_api
├── controller
│   └── user_controller.go
├── go.mod
├── go.sum
├── main.go
└── model
    ├── db.go
    ├── db_test.go
    ├── user.go
    ├── user_docker_test.go
    └── user_mock_test.go

 

コンテナを起動

 

まずは、MySQL用のコンテナを起動してみます。

model/db_test.go に処理を書いています。

 

model/db_test.go

type DBContainer struct {
    Resource *dockertest.Resource
    DB       *gorm.DB
}

func NewPool() (*dockertest.Pool, error) {
    return dockertest.NewPool("")
}

func NewDBContainer(pool *dockertest.Pool) (*DBContainer, error) {
    workDir, _ := os.Getwd()

    // コンテナ起動
    resource, err := pool.RunWithOptions(&dockertest.RunOptions{
        Repository: "mysql",
        Tag:        "8.0",
        Env:        []string{"MYSQL_ROOT_PASSWORD=secret"},
        Mounts: []string{
            workDir + "/testdata:/docker-entrypoint-initdb.d",
        },
    })

    if err != nil {
        return nil, err
    }

    dsn := fmt.Sprintf("root:secret@(localhost:%s)/test_db?parseTime=true", resource.GetPort("3306/tcp"))

    var db *gorm.DB

    // exponential backoff でコンテナの起動を待つ
    if err := pool.Retry(func() error {
        var err error

        db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
        if err != nil {
            return err
        }

        sqlDB, err := db.DB()
        if err != nil {
            return err
        }

        return sqlDB.Ping()
    }); err != nil {
        pool.Purge(resource)
        return nil, err
    }

    return &DBContainer{Resource: resource, DB: db}, nil
}

 

dockertest.NewPool()でdockerコンテナを操作するAPIへの接続である *dockertest.Poolを初期化します。

*dockertest.Pool を使ってコンテナの起動やコンテナの削除などを行います。

 

今回はNewPool()で*dockertest.Poolを初期化してNewDBContainer()に*dockertest.Poolを引数で渡すことで

DB用のコンテナを起動するようにします。

 

コンテナを起動するメソッドとして、dockerイメージをビルドしてdockerコンテナを起動する pool.BuildAndRun() と

dockerコンテナの起動だけを行うpool.Run()という2つのメソッドが用意されています。

さらに、それぞれのメソッドで詳細のオプションを設定できる

pool.BuildAndRunWithOptions()、pool.BuildAndRunWithBuildOptions()、pool.RunWithOptions() の3つのメソッドが用意されています。

 

各メソッドの定義は以下のようになります。

 

func (d *Pool) BuildAndRun(name, dockerfilePath string, env []string) (*Resource, error)

func (d *Pool) BuildAndRunWithBuildOptions(buildOpts *BuildOptions, runOpts *RunOptions, hcOpts ...func(*dc.HostConfig)) (*Resource, error)

func (d *Pool) BuildAndRunWithOptions(dockerfilePath string, opts *RunOptions, hcOpts ...func(*dc.HostConfig)) (*Resource, error)

func (d *Pool) Run(repository, tag string, env []string) (*Resource, error)

func (d *Pool) RunWithOptions(opts *RunOptions, hcOpts ...func(*dc.HostConfig)) (*Resource, error)

 

Run() では内部で RunWithOptions() を呼び出しているため、

 

Run(repository, tag, env)

 

 

RunWithOptions(&dockertest.RunOptions{
Repository: repository,
Tag: tag,
Env: env,
})

 

は同じように動きます。

 

今回は公式のMySQLのdockerイメージを使いたいという理由と、

コンテナの起動時にテストデータをDBの初期データをマウントしたいという理由から、

RunWithOptions() でコンテナの起動を行っています。

 

コンテナを起動した後、コンテナの起動が完了するまでGormによるDB接続を以下のようにpool.Retry()で待ちます。

 

    // exponential backoff でコンテナの起動を待ち、DBへ接続する
    if err := pool.Retry(func() error {
        var err error

        //DBへ接続する
        db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
        if err != nil {
            return err
        }

        sqlDB, err := db.DB()
        if err != nil {
            return err
        }

        //DBへのコネクションの確認
        return sqlDB.Ping()
    }); err != nil {

        //コンテナを削除する
        pool.Purge(resource)
        return nil, err
    }

 

Retry() は Exponential Backoff によってリトライをします。

デッドラインに達した際にはエラーを返すような仕様になっています。

 

これでコンテナの起動とDBへの接続の処理が実現できます。

 

テストコード

 

実際のテストコードを見てみましょう

 

model/user_docker_test.go

package model_test

import (
    "go_simple_api/model"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestFindUserByIdDocker(t *testing.T) {
    pool, err := model.NewPool()

    if err != nil {
        t.Fatal(err)
    }

    dbContainer, err := model.NewDBContainer(pool)

    if err != nil {
        pool.Purge(dbContainer.Resource)
        t.Fatal(err)
    }

    userModel := model.NewUserModel(dbContainer.DB)

    expect := model.User{
        ID:        1,
        FirstName: "Michael",
        LastName:  "Jordan",
    }

    user, err := userModel.FindById(1)

    if err != nil {
        pool.Purge(dbContainer.Resource)
        t.Fatal(err)
    }

    pool.Purge(dbContainer.Resource)
    assert.Equal(t, expect, user)
}

 

簡単に実装することができました。

 

dockertest を使わなくても事前にコマンドラインからdockerコンテナを立ち上げることで

実際のDBへ接続したテストを実現することは可能ですが、dockertestを使うのも簡単ですし、Goで実装できるので便利ですよね。

 

 

 

 

まとめ

 

ここまで読んでいただきありがとうございました。

 

いかがでしたか。今回はGoによるSQLテストについて見ていきました。

 

dockertestに関しては、SQLテスト以外の用途でも利用できそうです。

 

実際にテストを実行してみた結果、

go-sqlmockを使った場合は約0.008秒ほどで完了し、dockertestを使った場合は約14秒ほどで完了しました。

コンテナを使ったSQLテストは実行時間がネックになりそうですね。

ただし、テストの確実性は実際のDBへ接続したテストのほうが明らかに高いので、

どのプロジェクトでもDB周りのテストはコンテナを使ったテストの方を選択するのが良いかもしれません。

 

最後になりますが、EMoshUでは一緒に開発できる仲間をいつでも募集しております。

EMoshUブログを読んでご興味を持たれましたら、是非下記の募集要項をご覧ください。カジュアル面談なども実施しております!

 

 

 

 

 

参考文献

 

 

 

 

 

 

 

 

募集要項

 

 

 

 

まずは話を聞いてみたいという方へ

 

 

 

 

関連記事

Go言語でサクッとCUIツールを作ってみる|Engineering|EMoshU Blog|株式会社EMoshU

EMoshU Blog|Go言語の勉強も兼ねて、最小限のコードで並列処理やAPIの実行、JSONの取り扱い等を実際に実装して、簡単なCUIツールを作ってみました。

 

はじめてのDocker環境構築 〜 Dockerに触れてみよう!|Engineering|EMoshU Blog|株式会社EMoshU

EMoshU Blog|この度開発環境でDockerを使う機会がありましたので、その際に学習したことについて簡単にまとめてみました。