📝

Goのgomockを使って、状態が変わるAPIのテストを書く

2022/11/11に公開約6,000字

はじめに

とある開発で、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

プログラム

go.mod
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
)
pkg/client.go
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
}
pkg/interface.go
//go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../mock/$GOPACKAGE/$GOFILE

package pkg

type Client interface {
	Get() string
}
main.go
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 ./...

下記が生成されたコードです。

mock/pkg/interface.go
// 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

テストコードを書く

プログラム

main_test.go
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.NewMockClientctrl を渡すことでモックを作ります。

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

ログインするとコメントできます