モックテストとは
メタップスアドベントカレンダー15日目の記事です。
この記事で扱うモックテストとは、ユニットテストを行う際、テスト対象が依存している処理の振る舞いを他のものに置き換えてしまう手法のことです。例えば、データベースに依存したビジネスロジックをテストするときに、データベース処理部分についてはテストを行うのに都合のよいものに置き換えます。このようなことをすることで、何をテストするかに集中でき、ユニットテストを効率よく行うことができます。
私は業務ではGoを扱っていますので、Goにおけるモックテスト手法の紹介したいと思います。
モックテスト手法
モックテスト手法の説明のために、下記のように外部のAPIを利用して自サーバのIPアドレスを取得し、それをデータベースに保存する処理をテストすることにします。
package service
type IPAddressService struct {
fetcher IPAddressFetcher
repository Repository
}
func NewService(
fetcher IPAddressFetcher,
repository Repository,
) *IPAddressService {
return &IPAddressService{
fetcher: fetcher,
repository: repository,
}
}
func (service *IPAddressService) SaveIPAddress() error {
ipAddress, err := service.fetcher.Fetch()
if err != nil {
return err
}
item := Item{
IPAddress: ipAddress,
}
return service.repository.Save(&item)
}
package service
type Item struct {
IPAddress string
}
//go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../mock/$GOPACKAGE/$GOFILE
package service
type IPAddressFetcher interface {
Fetch() (string, error)
}
//go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../mock/$GOPACKAGE/$GOFILE
package service
type Repository interface {
Save(*Item) error
}
package ip_address_fetcher
import (
"encoding/json"
"net/http"
)
type IPAddressFetcher struct {
httpClient *http.Client
}
func NewIPAddressFetcher(
httpClient *http.Client,
) *IPAddressFetcher {
return &IPAddressFetcher{
httpClient: httpClient,
}
}
func (fetcher *IPAddressFetcher) Fetch() (string, error) {
resp, fetchErr := fetcher.httpClient.Get("https://httpbin.org/ip")
if fetchErr != nil {
return "", fetchErr
}
defer resp.Body.Close()
var payload struct {
Origin string
}
if decodeErr := json.NewDecoder(resp.Body).Decode(&payload); decodeErr != nil {
return "", decodeErr
}
return payload.Origin, nil
}
package persistence
import (
"gorm.io/gorm"
"MockTestSample/service"
)
type Repository struct {
db *gorm.DB
}
func (repository *Repository) Save(item *service.Item) error {
return repository.db.Transaction(func(tx *gorm.DB) error {
return tx.Create(item).Error
})
}
func NewRepository(
db *gorm.DB,
) *Repository {
return &Repository{
db: db,
}
}
gomock
gomockは、インターフェイス定義からモックを簡単に作成することができるライブラリです。テストを行う前にモックの振る舞いを定義すると、与えられた引数に対して決められた戻り値を返してくれます。事前準備した振る舞い以外の処理が発生した場合、エラーが発生し検知できます。これにより、実際にデータベースやAPIサーバへ接続することなくテストを行うことができます。このため、システム外のミドルウェアの振る舞いを気にすることなく、ビジネスロジックのテストに集中することができます。
事前準備
モックファイルはコマンドで生成します。コメントで上記サンプルのように記述しておくと、go generate ./...
コマンドで全モックを作成し直すことができるので便利です。
go install go.uber.org/mock/mockgen@latest
export PATH=$PATH:$HOME/go/bin
サンプル
依存するインターフェイスの実装を、テストするのに都合のいい振る舞いを行う処理に置き換えています。
package test
import (
"testing"
"go.uber.org/mock/gomock"
mock_service "MockTestSample/mock/service"
"MockTestSample/service"
)
func TestSaveIPAddressSuccessfully(t *testing.T) {
controller := gomock.NewController(t)
defer controller.Finish()
// 自サーバのIPアドレスを取得した結果 127.0.0.1 が返って来たと想定
mockFetcher := mock_service.NewMockIPAddressFetcher(controller)
mockFetcher.EXPECT().
Fetch().
Return("127.0.0.1", nil)
// 自サーバのIPアドレスを保存した結果、成功したと想定
mockRepository := mock_service.NewMockRepository(controller)
mockRepository.EXPECT().
Save(&service.Item{IPAddress: "127.0.0.1"}).
Return(nil)
srv := service.NewService(mockFetcher, mockRepository)
// IPアドレス取得、DB保存が成功した場合、処理全体が成功することをテストする
if err := srv.SaveIPAddress(); err != nil {
t.Fatal(err)
}
}
httpmock
ビジネスロジックだけでなく、APIとのやり取りもモックテストしましょう。httpmockでは、テスト対象がHTTP通信を行った時の振る舞いを、実際のHTTPサーバを用意することなくモック化することができます。アクセスする対象のURLと、期待するレスポンスを定義しましょう。
package infrastructure_test
import (
"net/http"
"testing"
"github.com/jarcoal/httpmock"
"MockTestSample/infrastructure/ip_address_fetcher"
)
func TestFetchIPAddress(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// https://httpbin.org/ip へアクセスした結果、JSONが返却されたことを想定
httpmock.RegisterResponder("GET", "https://httpbin.org/ip",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(http.StatusOK, &struct {
Origin string
}{
Origin: "127.0.0.1",
})
})
fetcher := ip_address_fetcher.NewIPAddressFetcher(http.DefaultClient)
ipAddress, err := fetcher.Fetch()
if err != nil {
t.Fatal(err)
}
if ipAddress != "127.0.0.1" {
t.Errorf("ip address is incorrect: %s", ipAddress)
}
}
sqlmock
データベースの振る舞いもモック化してテストしましょう。sqlmockでは、実際に動作するデータベースを用意することなく、システムが発行したSQLが想定通りかをテストします。
package infrastructure_test
import (
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"MockTestSample/infrastructure/persistence"
"MockTestSample/service"
)
func TestSave(t *testing.T) {
conn, mock, mockErr := sqlmock.New()
if mockErr != nil {
t.Fatalf("failed to open stub database connection [%#v]", mockErr)
}
defer func() {
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
conn.Close()
}()
db, err := gorm.Open(mysql.New(mysql.Config{
DriverName: "mysql",
Conn: conn,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open gorm connection [%#v]", err)
}
// トランザクションを開始し、INSERT文を発行したのちコミットする
mock.ExpectBegin()
mock.ExpectExec(
regexp.QuoteMeta("INSERT INTO `items` (`ip_address`) VALUES (?)"),
).
WithArgs("127.0.0.1").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
repository := persistence.NewRepository(db)
if saveErr := repository.Save(&service.Item{IPAddress: "127.0.0.1"}); saveErr != nil {
t.Fatalf("failed to save item [%#v]", saveErr)
}
}
ディレクトリ構成
最後に、全体のディレクトリ構成を載せておきます。
.
├── go.mod
├── go.sum
├── infrastructure
│ ├── ip_address_fetcher
│ │ └── fetcher.go
│ └── persistence
│ └── repository.go
├── main.go
├── mock
│ └── service
│ ├── fetcher.go
│ └── repository.go
├── service
│ ├── fetcher.go
│ ├── item.go
│ ├── repository.go
│ └── service.go
└── test
├── infrastructure
│ ├── ip_address_fetcher_test.go
│ └── repository_test.go
└── service
└── service_test.go
まとめ
CIで実際にデータベースに接続してしまい、全パターンをテストしているために時間がかかってしまうプロジェクトを見かけることがあります。モックテストは単体テストで何をテストしているかを明確にする効果があります。データベースなどのシステム境界外と繋げるのは統合テストで行い、網羅的なテストはモックで済ませる、というのがひとつの解決方法でしょう。