Goのgomockを使って、状態が変わるAPIのテストを書く
はじめに
とある開発で、SaaSのAPIを利用することがありました。これは、リソースの状態を返すAPIでした。
そのAPIのレスポンスパラメータは、Stateを持っており、一定時間が経過するとStateの状態が変わる仕様でした。仮に、下記のように状態が変わるものとします。
INIT => START => END
このようなプログラムのテストはめんどうです。正常系のテストは再現できるので比較的簡単に作れますが、失敗の場合は、API仕様通りのエラーを返してもらうパターンを見つけるのが困難です。そこで、gomockを使い、APIをモックにして、API仕様に記載されているStateごとのモックを作りテストをおこなう方法をご紹介します。
説明用のコード
ディレクトリ構成
.
├── go.mod
├── main.go
├── main_test.go
├── mock
│ └── pkg
│ └── interface.go
├── pkg
│ ├── client.go
│ └── interface.go
プログラム
module sample
go 1.18
require (
github.com/golang/mock v1.6.0
github.com/stretchr/testify v1.8.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
package pkg
type client struct {
State string
Num int
}
const (
INIT = "init"
START = "start"
END = "end"
)
func NewClient() Client {
return &client{
State: "",
Num: 0,
}
}
func (c *client) Get() string {
switch c.Num {
case 0:
c.State = INIT
case 1:
c.State = START
case 2:
c.State = END
}
c.Num++
return c.State
}
//go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../mock/$GOPACKAGE/$GOFILE
package pkg
type Client interface {
Get() string
}
package main
import (
"fmt"
client "sample/pkg"
)
func Run() {
client := client.NewClient()
fmt.Printf("Status=%s \n", client.Get()) // INIT
fmt.Printf("Status=%s \n", client.Get()) // START
fmt.Printf("Status=%s \n", client.Get()) // END
}
func main() {
Run()
}
解説
Run()
は、外部APIを呼び出すラッパー関数です。
client.NewClient()
は、利用している外部のAPIと置き換えてください。
client.Get()
を実行するたびに、State が変わる関数です。単純に呼び出した回数で、Stateが変わるようにした説明用のコードです。実際は、一定間隔でGet関数をポーリングをする想定です。
$ go run main.go
出力結果
Status=init
Status=start
Status=end
テスト用にモックを作る
gomockは、定義されたinterfaceを読み込んで、モックのコードを生成します。interfaceの定義は、pkg/interface.go に記載されています。
gomockのインストール
$ go install github.com/golang/mock/mockgen@v1.6.0
モックの生成
go generate
コマンドを使って、モックを作成します。
実行するコマンドは、pkg/interface.go のヘッドに記載されているコメント部分を読んで mockgen コマンドが実行されます。
//go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../mock/$GOPACKAGE/$GOFILE
go generateついて詳しくは、https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source に記載されています。
go generateを実行します。
$ go generate ./...
下記が生成されたコードです。
// Code generated by MockGen. DO NOT EDIT.
// Source: interface.go
// Package mock_pkg is a generated GoMock package.
package mock_pkg
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockClient is a mock of Client interface.
type MockClient struct {
ctrl *gomock.Controller
recorder *MockClientMockRecorder
}
// MockClientMockRecorder is the mock recorder for MockClient.
type MockClientMockRecorder struct {
mock *MockClient
}
// NewMockClient creates a new mock instance.
func NewMockClient(ctrl *gomock.Controller) *MockClient {
mock := &MockClient{ctrl: ctrl}
mock.recorder = &MockClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockClient) EXPECT() *MockClientMockRecorder {
return m.recorder
}
// Get mocks base method.
func (m *MockClient) Get() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get")
ret0, _ := ret[0].(string)
return ret0
}
// Get indicates an expected call of Get.
func (mr *MockClientMockRecorder) Get() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get))
}
このコードは、
// Code generated by MockGen. DO NOT EDIT.
と書かれているように、修正してはいけないコードです。interfaceを更新した場合は、go generate
を実行するスクリプトを用意すると良いでしょう。
go generate
は、gomock コマンドを実行しています。
-source
には、モック化したい、interfaceが期待されているファイル
-package
には、モックをライブラリとして読み込むときのパッケージ名。今回は、mock_pkg
になります。
-destination
には、生成する場所を指定します。
$ mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../mock/$GOPACKAGE/$GOFILE
テストコードを書く
プログラム
package main
import (
"testing"
mock_libs "sample/mock/pkg"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
client "sample/pkg"
)
func TestMain(t *testing.T) {
// モックの呼び出しを管理する Controller の生成
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// モックの生成
m := mock_libs.NewMockClient(ctrl)
t.Run("Run", func(t *testing.T) {
m.EXPECT().Get().Return(client.INIT)
assert.Equal(t, client.INIT, m.Get())
m.EXPECT().Get().Return(client.START)
assert.Equal(t, client.START, m.Get())
m.EXPECT().Get().Return(client.END)
assert.Equal(t, client.END, m.Get())
})
}
解説
モックの呼び出すコントローラーを作成します。
ctrl := gomock.NewController(t)
defer ctrl.Finish()
モックしたい対象 mock_libs.NewMockClient
に ctrl
を渡すことでモックを作ります。
m := mock_libs.NewMockClient(ctrl)
EXPECT()
は期待される呼び出しです。今回は、Get()
関数を呼び出し、Return()
で返してほしい戻り値を記載します。これをすることで、同じ Get()
でも異なる戻り値を変更したモックを作れます。
m.EXPECT().Get().Return(client.INIT)
m.Get()
で実際に関数を実行し、その結果を、assert.Equalで評価します。
assert.Equal(t, client.INIT, m.Get())
Get()
の中では、呼び出した回数で、状態が変わるようにしているため、モックを切り替えることで、レスポンス結果が変わった状態をテストできます。
m.EXPECT().Get().Return(client.START)
assert.Equal(t, client.START, m.Get())
m.EXPECT().Get().Return(client.END)
assert.Equal(t, client.END, m.Get())
テスト結果
$ go test -v
=== RUN TestMain
=== RUN TestMain/Run
--- PASS: TestMain (0.00s)
--- PASS: TestMain/Run (0.00s)
PASS
ok sample 0.245s
最後に
あまり、こういったAPIを利用することは少ないと思いますが、gomockを使うことで引数やレスポンス結果を変えたパターンのモックを作ることができるので、ぜひ色々試してみると良いでしょう。詳しくは、gomockのドキュメントに記載されています。
gomockの使い方は少々分かりづらいところがあるのであわせて、gomockを完全に理解するも読むのもおすすめです!
Discussion