Tags: Go Golang

gomock を使ってテストを書く

gomock を使ってモックを生成してテストを書く。

FYI:go-sqlmock で SQL 実行をモックするのは go-sqlmock でテストをとりあえず書く

開発環境

  • go version go1.18.1 linux/amd64

本文

Go でテストを書く場合、モックを gomock を利用して自動生成できるので、それを使ってテストを書く。

gomock

モック生成

まずモック生成ツールをインストールする。

go install github.com/golang/mock/[email protected]

下記のようにコマンドを打ってモックを生成する。

mockgen -source=./internal/repository/article/article.go -destination=./internal/mock/mock_article/mock_article.go

source がモックを作りたいソースコードのパス、destination がモックのファイル生成先である。

モックのパッケージ名は「mock_[元のパッケージ名]」になるようなので、生成先のディレクトリ・ファイル名はそれを見越しておくと良いと思う。

また注意点として private メソッドはモックが生成されない。

テストを書く

テストしたいソースが下記であるとする。

package mparameter

import (
	"gitlab.com/k1350/sololog_gql/graph/model"
	"gitlab.com/k1350/sololog_gql/internal/repository/mparameter"
)

type IMParameterUsecase interface {
	Get() (*model.MasterParameter, error)
}

type MParameterUsecase struct {
	mParameterRepository mparameter.IMParameterRepository
}

func NewMParameterUsecase(r mparameter.IMParameterRepository) IMParameterUsecase {
	return &MParameterUsecase{mParameterRepository: r}
}

func (u *MParameterUsecase) Get() (*model.MasterParameter, error) {
	return u.mParameterRepository.Find()
}

これをテストするために u.mParameterRepository.Find() を gomock でモック化する。
これがモック対象のソースコードで、これが自動生成したモックである。

テスト例は下記のようになる。

t.Run("正常系", func(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    m := mock_mparameter.NewMockIMParameterRepository(ctrl)
    u := NewMParameterUsecase(m)
    expected := &model.MasterParameter{
        MaxBlogs:     5,
        MaxBlogLinks: 3,
    }
    m.
        EXPECT().
        Find().
        Return(expected, nil)
    result, err := u.Get()
    if err != nil {
        t.Error("got err:", err)
    }
    if !reflect.DeepEqual(result, expected) {
        t.Errorf("got:\n%v\n\nwant:\n%v", result, expected)
    }
})

まず

ctrl := gomock.NewController(t)
defer ctrl.Finish()

でコントローラを生成する。
次に

m := mock_mparameter.NewMockIMParameterRepository(ctrl)

でモック対象を生成する。

引数や戻り値は

m.
    EXPECT().
    Find().
    Return(expected, nil)

の部分で指定する(この例は引数が無い)。

異常系も含めたテスト例はこんな感じ

EXPECT の記述が不足している、または EXPECT が書いてあるのに呼ばれなかった場合はテスト実行時にエラーとなる。

sql-mock と併用する

下記のようにリポジトリの外でトランザクションを張っている場合

func (u *BlogUsecase) DeleteBlog(now time.Time, blogId string, userId int) error {
	intId, _ := strconv.ParseInt(blogId, 10, 64)

	// 最初に削除対象の存在チェック&権限チェックする
	blog, err := u.blogRepository.FindById(intId, userId)
	if err != nil {
		return err
	}
	if blog == nil {
		return customError.NotFoundError
	}

	// トランザクション開始
	tx, err := u.blogRepository.BeginTransaction()
	if err != nil {
		return err
	}
	defer func() {
		// panicが起きたらロールバック
		if p := recover(); p != nil {
			tx.Rollback()
			panic(p)
		}
	}()

	err = u.blogRepository.DeleteById(tx, now, intId, userId)
	if err != nil {
		tx.Rollback()
		return err
	}

	err = u.articleRepository.DeleteByBlogId(tx, now, intId)
	if err != nil {
		tx.Rollback()
		return err
	}

	tx.Commit()
	return nil
}

テストの際、sql-mock と gomock によるモックを併用する。

以下に書いたテストコードを抜粋する。

func TestDeleteBlog(t *testing.T) {
	now := time.Now().UTC()
	var blogId int64 = 2
	stringBlogId := "2"
	blog := &model.Blog{
		ID:            "1",
		BlogKey:       "dccb4797-90c7-ea22-6866-7eb954acbbda",
		Author:        "著者1",
		Name:          "ブログ名1",
		Description:   "",
		PublishOption: model.PublishOptionPublic,
		CreatedAt:     now.Format(time.RFC3339),
		UpdatedAt:     now.Format(time.RFC3339),
	}

	tests := []struct {
		name             string
		blogFindExpected *model.Blog
		blogFindErr      error
		deleteBlogErr    error
		deleteArticleErr error
		wantErr          bool
	}{
		{
			name:             "正常系",
			blogFindExpected: blog,
			blogFindErr:      nil,
			deleteBlogErr:    nil,
			deleteArticleErr: nil,
			wantErr:          false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			db, dbmock, err := sqlmock.New()
			if err != nil {
				fmt.Println("failed to open sqlmock database:", err)
			}
			defer db.Close()

			ctrl := gomock.NewController(t)
			defer ctrl.Finish()
			mb := mock_blog.NewMockIBlogRepository(ctrl)
			ma := mock_article.NewMockIArticleRepository(ctrl)
			u := NewBlogUsecase(mb, ma)

			mb.
				EXPECT().
				FindById(blogId, 1).
				Return(tt.blogFindExpected, tt.blogFindErr)
			if tt.blogFindExpected != nil && tt.blogFindErr == nil {
				dbmock.ExpectBegin()
				tx, err := db.Begin()
				if err != nil {
					fmt.Println("failed to begin transaction:", err)
				}

				mb.
					EXPECT().
					BeginTransaction().
					Return(tx, nil)

				mb.
					EXPECT().
					DeleteById(tx, now, blogId, 1).
					Return(tt.deleteBlogErr)

				if tt.deleteBlogErr == nil {
					ma.
						EXPECT().
						DeleteByBlogId(tx, now, blogId).
						Return(tt.deleteArticleErr)

					dbmock.ExpectCommit()
				}
			}
			err = u.DeleteBlog(now, stringBlogId, 1)
			if tt.blogFindErr == nil && err != nil {
				dbmock.ExpectRollback()
			}
			if (err != nil) != tt.wantErr {
				t.Error("got err:", err)
			}
		})
	}
}

ソースコード全体はここ

以上