gomockでaws-sdk-goをモック/スタブする
普段、Rubyを書いていると、Goでaws-sdkを扱う際にAPIの呼び出しをモック/スタブするのが意外と大変で苦労する。よくGoのテストのプラクティスとして、スタブしたい関数をグローバル変数に入れておいて、テスト時にすり替えるというのがあるが、もうちょっとなんとかならないかと思ったりする。
以前、gomockを使った、いい感じのaws-sdk-goのモック/スタブ方法を見かけて、それ以来、実践しているのだけれど、残念なことにその元記事が消えてしまった(たぶん)。せっかくなので、この方法論をまとめておくことにした。
gomockとは何か
これがとてもよくまとまっているので、これを読めばいいと思う。
流石に丸投げはアレなので、簡単に説明すると、interfaceを元にモック用のGoのコードを生成してくれる。aws-sdk-goはこういったテストのために各APIのinterfaceを公開しているので、それをターゲットにしてモック用のコードを自動生成できる。
例えば、go generateを使うならば、関連するコードの付近に以下のように忍ばせておく。
...
//go:generate mockgen -source vendor/github.com/aws/aws-sdk-go/service/ec2/ec2iface/interface.go -destination mock/ec2.go -package mock
...
mockgenはgomockの中に含まれるCLIで、以下のようにすればインストールできる。go generate ./... とかと一緒にMakefileに書いておくと便利。
$ go get -u github.com/golang/mock/mockgen
モックを使う
モック用のコードを自動生成してもRSpecのように魔法の一行をテスト側に書けば、実行時にAPIクライアントがすり替わる、みたいな都合の良いことは起こらない。なので、以下のようなコードはテストできない。
package main import ( "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" ) func main() { s := session.New(&aws.Config{Region: aws.String("us-east-1")}) client := ec2.New(s) resp, err := client.DescribeInstances(nil) if err != nil { panic(err) } for _, r := range resp.Reservations { for _, i := range r.Instances { fmt.Println(aws.StringValue(i.InstanceId)) } } }
じゃあどうするのかというと、このAPIクライアントをラップして、テスト時にモックで置き換えられるようにする。
package main import ( "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" ) func main() { s := session.New(&aws.Config{Region: aws.String("us-east-1")}) client := ec2.New(s) printInstances(client) } func printInstances(client ec2iface.EC2API) { resp, err := client.DescribeInstances(nil) if err != nil { panic(err) } for _, r := range resp.Reservations { for _, i := range r.Instances { fmt.Println(aws.StringValue(i.InstanceId)) } } }
ポイントは printInstances の引数の型がinterfaceであることで、gomockで生成されたモックは対象となるinterfaceを実装しているため、テスト時にはここにモックを差し込むことができる。
package main import ( "testing" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/golang/mock/gomock" "github.com/yourname/reponame/mock" ) func TestPrintInstances(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() ec2Mock := mock.NewMockEC2API(ctrl) ec2Mock.EXPECT().DescribeInstances(nil).Return(&ec2.DescribeInstancesOutput{ .... }, nil) printInstances(ec2Mock) ... }
次のポイントはこの EXPECT() の呼び出しの部分で、これでメソッドの呼び出しをモックできる。メソッドチェーンがGoっぽくないが、RSpecっぽく書けるのがちょっとうれしい。
gomockについては言及されていないが、interfaceを活用したテストのテクニックについては、AWSも公式にブログで記事を書いているので、目を通しておくと良いと思う。
gomockを使ったテクニック
これだけだと、結局関数を変数に入れてすり替えるのと対して変わんないじゃんという気分にもなるが、gomockには色々便利な機能がある。例えば、最初の記事にもあった通り、呼び出しの順番や回数をテストするのも簡単にできる。
後、便利なのは、呼ばれるかもしれないし、呼ばれないかもしれない、みたいなAPI呼び出しをスタブしたい場合に、AnyTimes() が使える。
ec2Mock.EXPECT().DescribeInstances(nil).Return(&ec2.DescribeInstancesOutput{ .... }, nil).AnyTimes()
EXPECT() なのにallow的な振る舞いをするのはRSpec脳的にはちょっと違和感あるが...
もっとよくある例としては、2回目の呼び出し時点ではレスポンスを変える、みたいなパターン。これは Do() を使って、その中で改めて EXPECT() することで、レスポンスを変えることができる。
ec2Mock.EXPECT().DescribeInstances(nil).Do(func(input *ec2.DescribeInstancesInput) { ec2Mock.EXPECT().DescribeInstances(nil).Return( ... // 2回目のレスポンス }, nil) }).Return(&ec2.DescribeInstancesOutput{ .... // 1回目のレスポンス }, nil)
これができたときの感想
なんかよくわからん技術ですべてがうまくいく
— wata (@wata727_) 2018年2月23日
利用例
最近作った herogate というCLIでは、urfave/cli を使っているのだけれど、このライブラリによって呼ばれる各コマンドにマッピングされるメソッドの中では、あまりコードは書かないようにして、process で始まるメソッドの中にテストしたい処理を書くようにしている。
// Apps returns your apps. func Apps(ctx *cli.Context) { processApps(&appsContext{ app: ctx.App, client: api.NewClient(&api.ClientOption{Region: "us-east-1"}), }) } func processApps(ctx *appsContext) { ... }
で、テスト時にはこの processApps だけをテスト対象として、clientをモックで置き換える。Context という名前がちょっと微妙かなという気がしないでもないが、CLIを作るという用途について言えば、こんな感じでうまくいくんじゃないかと思う。
たぶん一番良い例は ecs-cli でこれもgomockを使ったAPI呼び出しのモックを色々やっているので、テストを覗いてみると良さそう。
余談
ちょっと前に aws-sdk-go-v2 が出ていて、最初はこれを使おうと思っていたんだけど、v1と違って、リクエストを生成してSendするスタイルに統一されたために、上記の方法が使えなくなってしまった。
同じことを考えている人は既にいたらしく、これはissueが立っていて、なんとかする予定はあるらしい。stringとかをいちいち aws.String() みたいにせずに扱えるようになっている部分があったりして、Goらしく改善している部分があるっぽいので、早く使いたい。