2020-02-10

gomockはともだち こわくないよ!

(キャプ翼は読んだことないです)

今回は gomock を使ったテストコードが読めるようになるために記事としました。 モックの雰囲気を感じ取って貰えれば嬉しいです。

多少癖はありますが、Python の mockと比べればシンプルでわかりやすいと思います。

([python]まだmockで消耗してるの?mockを理解するための3つのポイント)

Sample

最初に、今回モックする対象のプログラムについて簡単に説明します。

world/world.go に、ターゲット を指定するとそのコンテンツの内容を標準出力に表示する Print という関数を作り、 main.go でそれを呼び出しているだけです。

  • world.go
    package world
    
    import (
    	"fmt"
    	"io/ioutil"
    	"net/http"
    	"os"
    )
    
    // Reader is an interface
    type Reader interface {
    	Read(target string) string
    }
    
    // Print is a function
    func Print(r Reader, target string) {
    	body := r.Read(target)
    	fmt.Println(body)
    }
    
    // File is a struct
    type File struct{}
    
    // Read is a method of File
    func (f File) Read(target string) string {
    	fd, _ := os.Open(target)
    	defer fd.Close()
    	body, _ := ioutil.ReadAll(fd)
    	return string(body)
    }
    
    // Site is a struct
    type Site struct{}
    
    // Read is a method of Site
    func (s Site) Read(target string) string {
    	res, _ := http.Get(target)
    	defer res.Body.Close()
    	body, _ := ioutil.ReadAll(res.Body)
    	return string(body)
    }
    
    
  • main.go
    package main
    
    import (
    	// カレントディレクトリは go.mod によって hello というモジュール名がつけられている
    	"hello/world"
    )
    
    func main() {
    	s := world.Site{}
    	world.Print(s, "http://note.crohaco.net/test.txt")
    }
    
    

少し複雑なところを挙げるなら、 この Print という関数は抽象化のために Reader というインタフェースを第一引数にとり、第二引数で具体的なターゲットを指定できるようにしました。

(引数としてのインタフェース)

記事の趣旨とは関係ないように見えますが、 gomock はインターフェースを対象にモックを作成するため呼び出し元の関数は インタフェースによって抽象化されている必要があります。

この抽象化により Print は対象がファイルであってもサイトであっても同じように呼び出せるというわけです。

せっかくなら target も構造体のフィールドにしろよという声が聞こえてきそうですが、大人(記事)の事情により引数にしました。

main.go ではサイトのコンテンツを取得するように Site 構造体とURLを指定しているので、 サイトのコンテンツ内容として以下のように表示されるはずです。

$ go run main.go aaaaa

多少の文句はありますが、比較的良いプログラムが書けた気がします。 でもこれで終わりじゃありません。テストがなければ不安ですね。

しかし、いざユニットテストを書こうと思ったときに外部へアクセスする処理は呼び出したくないものです。 今回のプログラムで言えば、 Site.Read は外部へアクセスするメソッドなのでできればテストでは呼び出したくないと思います。

そこで Site.Read はモックすることにしましょう。

Installation

モックするには gomockmockgen のインストールが必要です。

左のようにインストールします。 最初にモジュールモードを有効にしているので右のような go.mod が出来上がります。

  • $ go mod init hello go: creating new go.mod: module golang-mock $ go get github.com/golang/mock/gomock go: finding github.com/golang/mock v1.4.0 go: downloading github.com/golang/mock v1.4.0 go: extracting github.com/golang/mock v1.4.0 $ go get github.com/golang/mock/mockgen go: downloading golang.org/x/tools v0.0.0-20190425150028-36563e24a262 go: extracting golang.org/x/tools v0.0.0-20190425150028-36563e24a262 go: finding golang.org/x/tools v0.0.0-20190425150028-36563e24a262
  • go.mod
    module hello
    
    go 1.13
    
    require github.com/golang/mock v1.4.0
    
    

準備はこれでおわりです。

Mocking

上記でインストールした mockgen コマンドを実行すると -destination オプションに指定したパスに モックのコードが自動生成されます。

# world ディレクトリに移動して world/ $ mockgen -source world.go -destination mock_world/mock_world.go
mock_world.go
// Code generated by MockGen. DO NOT EDIT.
// Source: world.go

// Package mock_world is a generated GoMock package.
package mock_world

import (
	gomock "github.com/golang/mock/gomock"
	reflect "reflect"
)

// MockReader is a mock of Reader interface
type MockReader struct {
	ctrl     *gomock.Controller
	recorder *MockReaderMockRecorder
}

// MockReaderMockRecorder is the mock recorder for MockReader
type MockReaderMockRecorder struct {
	mock *MockReader
}

// NewMockReader creates a new mock instance
func NewMockReader(ctrl *gomock.Controller) *MockReader {
	mock := &MockReader{ctrl: ctrl}
	mock.recorder = &MockReaderMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockReader) EXPECT() *MockReaderMockRecorder {
	return m.recorder
}

// Read mocks base method
func (m *MockReader) Read(target string) string {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Read", target)
	ret0, _ := ret[0].(string)
	return ret0
}

// Read indicates an expected call of Read
func (mr *MockReaderMockRecorder) Read(target interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockReader)(nil).Read), target)
}

モックのコードがインタフェース(このプログラムでは Reader)に対して作られていることがわかるでしょうか。

このプログラムを使いテストコードを書いていきます。

world_test.go
package world

import (
	"testing"

	"hello/world/mock_world"

	"github.com/golang/mock/gomock"
)

func TestPrint(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	m := mock_world.NewMockReader(ctrl)
	m.
		EXPECT().
		Read("http://note.crohaco.net/test.txt").
		Return("bbbbb")

	Print(m, "http://note.crohaco.net/test.txt")

	m2 := mock_world.NewMockReader(ctrl)
	m2.
		EXPECT().
		Read(gomock.Any()).
		Return("ccccc")

	Print(m2, "anything is ok")
}

関数内の先頭2行はおまじないのようなもので、思考停止ですべてのテストケースに書けばよいです。

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

というと怒られそうなので少し補足すると defer ctrl.Finish() は後処理として (後述する)スタブがすべて呼び出されたかどうかをチェックしています。

Stub

モックは NewMock{インタフェース名} 関数で作ります。 作成したモックに対して どのように振る舞うかを指定するのが EXPECT() 以下のメソッドチェーンです。

info
  • EXPECT()gomock.Call のアドレスを返却し、それ以降のメソッドも同じ gomock.Call のアドレスを返すことでメソッドチェーンを実現しているようです。

この定義された振る舞いが スタブ です

Print はモックを受け取ると内部で m.Read を呼び出すため、定義したスタブが実行されます。

スタブに定義していない引数を指定するとその時点で FAIL になりますし、一度も呼び出されなくてもエラーになります。

  • 定義していない引数を指定した
  • 一度も呼び出さなかった
  • --- FAIL: TestPrint (0.00s) world.go:39: Unexpected call to *mock_world.MockReader.Read([http://note.crohaco.net/not_found.txt]) at golang-mock/world/mock_world/mock_world.go:38 because: expected call at golang-mock/world/world_test.go:18 doesn't match the argument at index 0. Got: http://note.crohaco.net/not_found.txt Want: is equal to http://note.crohaco.net/test.txt panic.go:563: missing call(s) to *mock_world.MockReader.Read(is equal to http://note.crohaco.net/test.txt) golang-mock/world/world_test.go:18 panic.go:563: aborting test due to missing call(s) FAIL exit status 1 FAIL hello/world 0.021s
  • --- FAIL: TestPrint (0.00s) world_test.go:30: missing call(s) to *mock_world.MockReader.Read(is equal to http://note.crohaco.net/test.txt) golang-mock/world/world_test.go:18 world_test.go:30: aborting test due to missing call(s) FAIL exit status 1 FAIL hello/world 0.022s
  • gomock.Any() で引数を定義すれば、どのような引数でも FAIL しません。
  • AnyTimes() をメソッドチェーンに追加すれば呼び出されなくても FAIL しません。

"http://note.crohaco.net/test.txt" が与えられたときの振る舞いとして "bbbbb", "ccccc" を返すようにしているので、テスト実行時にはサイトのコンテンツ(aaaaa)ではなく、 bbbbb, ccccc が順に標準出力に表示されます。

world $ go test bbbbb ccccc PASS ok hello/world 0.030s

ここでは Call.Return しか使っていませんが、他にも呼び出し回数を制限する Call.Times や、呼び出し順を制御する Call.After, gomock.InOrder などもあるので、気になる方はドキュメントを御覧ください。

というわけで前述しましたが gomock を使うにはインタフェースを引数にして関数を書く必要があります。 こういう部分で抽象化を強制(矯正)してくるところに Golang の粋を感じますね。

実務のコードはもう少し呼び出し関係が複雑だと思いますが、やることは概ね同じです。

info
  • gomock はモンキーパッチ機能は提供していないので、サードパーティを使う必要があるみたいです。
  • https://github.com/bouk/monkey

参考

https://github.com/golang/mock https://qiita.com/tenntenn/items/24fc34ec0c31f6474e6d https://qiita.com/gold-kou/items/81562f9142323b364a60