GoでAWS APIをモックする
開発中のCLIツールのテストで、AWS API の呼び出しをモックする仕組みを試行錯誤したのでメモ。
ツールでは aws-sdk-go を用いてAPIを呼び出している。
例として、CloudFormation の ListStacks API をモックする。
サービスクライアントを差し替え可能にする
before
import ( "github.com/aws/aws-sdk-go/service/cloudformation" ) service := cloudformation.New(sess) params := &cloudformation.ListStacksInput{ NextToken: token, } resp, err := service.ListStacks(params)
サービスごとにaws-sdk-go/service/*
にサービスクライアントの構造体が定義されていて、通常はそれを使う。
が、そのままハードコーディングしてしまうとテストで差し替えることができなくなってしまう。
このサービスクライアントだが、便利なことにgithub.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface.CloudFormationAPI
というインターフェースを実装しているので、インターフェースを経由すれば実装を気にすることなく各APIを呼び出すことができる。
この仕組みを利用して、サービスクライアントを差し替え可能にする。
after
var Client cloudformationiface.CloudFormationAPI if Client == nil { Client = cloudformation.New(sess) } params := &cloudformation.ListStacksInput{ NextToken: token, } resp, err := Client.ListStacks(params)
Client
をグローバルに出して(複数のプライベートメソッドから呼ばれるため)、呼び出し時にClient
が設定されていなければaws-sdk-goの実装を使う、という仕組みにする。
これによって、テストでは事前にClientにモックオブジェクトを差し込んで、任意の処理をさせることができるようになる。
モックの実装
次に、テストで使うモックの実装。
type mockClient struct { cloudformationiface.CloudFormationAPI ListStacksMock func(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) ListExportsMock func(params *cloudformation.ListExportsInput) (*cloudformation.ListExportsOutput, error) ListImportsMock func(params *cloudformation.ListImportsInput) (*cloudformation.ListImportsOutput, error) }
以下の記事が詳しいが、Goでは構造体に直接インターフェースを埋め込むDIの仕組みがあるのでそれを活用する。 qiita.com
cloudformationiface.CloudFormationAPI
を埋め込んだモックの構造体を作り、各メソッドの実装をケースごとに差し替えられる様に、関数オブジェクトとして定義する。
ケースごとにオーバライドできる & デフォルトの実装をしておく。
func (client *mockClient) ListStacks(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) { if client.ListStacksMock != nil { return client.ListStacksMock(params) } switch aws.StringValue(params.NextToken) { case "": return &cloudformation.ListStacksOutput{ NextToken: aws.String("next_token"), StackSummaries: []*cloudformation.StackSummary{ {StackId: aws.String("aaa"), StackName: aws.String("foo")}, {StackId: aws.String("bbb"), StackName: aws.String("bar")}, }, }, nil case "next_token": return &cloudformation.ListStacksOutput{ NextToken: nil, StackSummaries: []*cloudformation.StackSummary{ {StackId: aws.String("ccc"), StackName: aws.String("foobar")}, }, }, nil default: return nil, nil } }
モックすればページングの挙動とかも簡単に確認できますね...!
例えばパーミッションエラー時のテストをしたい場合は、エラーのレスポンスを返す実装を差し込む。
t.Run("authorization error for ListStacks", func(t *testing.T) { cmd := NewCmd() var args []string const errorCode = "AccessDeniedException" const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStacks operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStacks" Client = &mockClient{ ListStacksMock: func(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) { return nil, awserr.New(errorCode, errorMsg, errors.New("hoge")) }, } actual, err := FetchData(cmd, args) assert.Empty(t, actual) assert.Equal(t, errorCode, err.(awserr.Error).Code()) assert.Equal(t, errorMsg, err.(awserr.Error).Message()) })
awserrの実装を見て、エラーレスポンスを組み立てる。 docs.aws.amazon.com
OrigError()
はあんまり使う気がしないので、該当のCode()
とMessage()
だけで十分再現性あると思う。
type Error interface { // Satisfy the generic error interface. error // Returns the short phrase depicting the classification of the error. Code() string // Returns the error details message. Message() string // Returns the original error if one was set. Nil is returned if not set. OrigErr() error }
エラーコードはこの辺りで確認する。