概要
モック化させたインターフェースの関数に、期待した値が入って呼ばれるかどうかをテストしたいシーンのお話です。
引数に構造体のポインタを受け取る関数をテストする時、
- テストスイート内でアサーションのために宣言したオブジェクト
- 実際のビジネスロジックの中で生成されたオブジェクト
はポインタが異なるため、オブジェクトのチェックが難しくなります。
gomockを使っている際に、この問題を解決する方法をまとめます。
GoMock
GitHub: https://github.com/golang/mock
今回使用したバージョンはv1.4.3です。
ユースケース
概要だけだとイメージが湧きにくいかと思いますので、ここからは例を交えて説明します。
ユースケースは下記のようにシンプルなものを用意します。
- タイトルと著者名を入力して、Bookオブジェクトを作成する
- タイトルに空文字を入れた時には、Bookオブジェクトのタイトルが「no title」になる
テストケース
テストでは、
- When: ユースケースの関数に空文字のタイトルを渡した時
- Then: DBに保存されるエンティティのタイトルに「no title」がセットされている
ということをチェックします。
ビジネスロジック実装
Entity
ID、 タイトル、 著者名を持ちます。
package entity
type Book struct {
ID uint64
Title string
Author string
}
func NewBook(title, author string) Book {
return Book{
Title: title,
Author: author,
}
}
Repository
entityを永続化させるインターフェースです。
package repository
import "github.com/.../entity"
type BookRepository interface {
// Bookオブジェクトのポインタを受け取り、DBに保存する
Save(book *entity.Book) error
}
RepositoryのSave関数がポインタを受け取っています。
このインターフェースを、GoMockでモック化させます。
Interactor
そしてユースケースに該当する、Interactorの関数を今回はテストします。
package service
import (
"fmt"
"github.com/.../entity"
"github.com/.../repository"
)
type BookInteractor struct {
bookRepository repository.BookRepository
}
func (bi BookInteractor) CreateBook(title, author string) error {
// titleが空の場合はno titleを入れる
if title == "" {
title = "no title"
}
// entity作成
book := entity.NewBook(title, author)
// entityをDBに保存
err := bi.bookRepository.Save(&book)
if err != nil {
return fmt.Errorf("failed to save book: %w", err)
}
return nil
}
テストコード
失敗するケース
package service
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/.../entity"
mock_repository "github.com/.../mock/repository"
)
func TestBookInteractor_CreateBook(t *testing.T) {
controller := gomock.NewController(t)
defer controller.Finish()
t.Run("succeed when name is empty", func(t *testing.T) {
// テストする関数の引数に入れる値
argTitle := "" // When: タイトルに空を指定する
argAuthor := "taro"
// bookRepositoryをモック化
bookRepository := mock_repository.NewMockBookRepository(controller)
// bookRepository.Save関数の引数に期待するオブジェクト
expectedBook := entity.Book{
ID: 0,
Title: "no title", // Then: no titleがセットされている
Author: "taro",
}
// bookRepository.Save関数に期待した引数が入って呼ばれるかチェック
bookRepository.EXPECT().Save(&expectedBook).Return(nil)
interactor := BookInteractor{
bookRepository: bookRepository,
}
// action: テスト対象関数呼び出し
err := interactor.CreateBook(argTitle, argAuthor)
if err != nil {
t.Fatal(err)
}
})
}
Save
関数が呼ばれる時の引数の構造体の中身は、想定する expectedBook
と同じはずですが、このケースでは失敗します。
なぜなら、Save
関数が呼ばれる時には引数がポインタで渡されています。
そして、この expectedBook
と、interactorの中で NewBook
で生成されたオブジェクトの中身は同じですが、オブジェクト自体のポインタが異なるためにエラーが吐かれます。
この場合にどうすれば良いかというと、2つやり方があります。
- 構造体の中身をアサーションするカスタムMatcherを作る
- mockに用意された
Do
メソッドを使って、構造体の中身を1つずつアサーションする
前者は少々面倒なので、後者で回避します。
成功するケース
package interactor
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/.../entity"
mock_repository "github.com/.../mock/repository"
"github.com/stretchr/testify/assert"
)
func TestBookInteractor_CreateBook(t *testing.T) {
controller := gomock.NewController(t)
defer controller.Finish()
t.Run("succeed when name is empty", func(t *testing.T) {
// テストする関数の引数に入れる値
argTitle := "" // When: タイトルに空を指定する
argAuthor := "taro"
// bookRepositoryをモック化
bookRepository := mock_repository.NewMockBookRepository(controller)
// bookRepository.Save関数に期待した引数が入って呼ばれるかチェック
bookRepository.EXPECT().Save(gomock.Any()).Return(nil).Do(func(actualBook *entity.Book) {
// 変更箇所
assert.Equal(t, "no title", actualBook.Title) // Then: no titleがセットされている
assert.Equal(t, "taro", actualBook.Author)
})
interactor := BookInteractor{
bookRepository: bookRepository,
}
// action: テスト対象関数呼び出し
err := interactor.CreateBook(argTitle, argAuthor)
if err != nil {
t.Fatal(err)
}
})
}
GoMockには、モックオブジェクトの関数の処理後にフックされる Do
メソッドがあり、それを使用します。
Do
に渡すコールバック関数の中に、Save
メソッドが呼ばれた時の実際の引数が入ってきます。
それを対象にコールバック関数内でアサーションを行います。
ポインタで引数が渡されるなら、渡ってきたオブジェクトの中身を1つずつアサーションしてしまおう という戦法です。
なお、Save
関数の引数に、gomock.Any()
という「どんな引数でも期待する」というMatcherを入れないと、モック関数の呼び出しでコケます。
最後に
GoMockで、構造体オブジェクトのポインタを受け取る関数の引数をテストする方法をまとめました。
もしもっとスマートにテストできる方法があれば教えていただけますと嬉しいです!