AWS SDK for Go V2でユニットテストを書く際に、interfaceのモックを作らなくて済むような良い方法があったので、使えるパターンをいくつかまとめてみました。
目次
概要
AWS SDK for Go V2を使ったアプリケーションでユニットテストを書く際に、おそらくAWS SDKのClientのinterfaceを作って、スタブを作ったりまたはgomockを使ったりなどでモックしてテストコードを書くと思います。
しかし、テストのためにいちいちSDKのClientのinterfaceを作るのも大変だなと思い、interfaceを使ってモックしなくて済むテスト方法を書いてみます。
Middleware
内容
手法としては、 「AWS SDK for Go V2が提供しているMiddleware機能を用いてSDKのレスポンスをモックする」といったものになります。
つまり、SDKのClientをそのまま使ってテストをすることができます。
しかし、公式の次の章の「Testing」にて、テストはinterfaceを使ってモックすることを推奨して書かれているので、今回ご紹介するMiddlewareが適切なテスト手法かと言うと微妙なところですが、とても便利なので今回まとめてみました。
仕組み
AWS SDK for Go V2のリクエストからレスポンスにかけて、こちらの図のようなスタックという各ステップに基づいて処理されます。
Stack Step | Description |
---|---|
Initialize | Prepares the input, and sets any default parameters as needed. |
Serialize | Serializes the input to a protocol format suitable for the target transport layer. |
Build | Attach additional metadata to the serialized input, such as HTTP Content-Length. |
Finalize | Final message preparation, including retries and authentication (SigV4 signing). |
Deserialize | Deserialize responses from the protocol format into a structured type or error. |
この各スタックステップのinput/outputをカスタマイズすることで、モックすることができます。
使い方(概略)
middleware関数の定義
ざっくりとした使い方の説明ですが、各スタックごとに用意されたmiddleware関数を使ってmiddlewareの挙動を定義し、SDK側へ渡すことでmiddlewareを適用させます。
以下は、InitializeMiddlewareFunc
という、上記ステップの一番最初のInitializeステップの、Clientのリクエストのパラメーター(xxInput)をカスタマイズするmiddleware関数例になります。
※s3.GetObjectInput
呼び出しの場合に、リクエストパラメータにBucket
がないときはBucket
に"my-default-bucket"を指定する。
var defaultBucket = middleware.InitializeMiddlewareFunc("DefaultBucket", func( ctx context.Context, in middleware.InitializeInput, next middleware.InitializeHandler, ) ( out middleware.InitializeOutput, metadata middleware.Metadata, err error, ) { // Type switch to check if the input is s3.GetObjectInput, if so and the bucket is not set, populate it with // our default. switch v := in.Parameters.(type) { case *s3.GetObjectInput: if v.Bucket == nil { v.Bucket = aws.String("my-default-bucket") } } // Middleware must call the next middleware to be executed in order to continue execution of the stack. // If an error occurs, you can return to prevent further execution. return next.HandleInitialize(ctx, in) })
middleware関数の渡し方
具体的なmiddleware関数のSDKへの渡し方ですが、以下の2パターンになります。
これらは「オプションの注入の仕方・タイミング」という違いになりますが、具体的には以下のような特徴があります。
①(SDKの)configの定義時に、Optionとして注入する
こちらは、各Clientの生成時に使うconfigのAPIOptions
に渡します。
また渡す際、先ほど定義した関数を以下のようにstack.Initialize.Add(defaultBucket, middleware.Before)
を返す関数としてラップすることで、middlewareを各ステップにアタッチできます。
cfg, err := config.LoadDefaultConfig(context.TODO()) if err != nil { // handle error } cfg.APIOptions = append(cfg.APIOptions, func(stack *middleware.Stack) error { // Attach the custom middleware to the beginning of the Initialize step return stack.Initialize.Add(defaultBucket, middleware.Before) }) client := s3.NewFromConfig(cfg)
特徴としては、以下があげられます。
- 各Client・各オペレーションで共通定義ができる
- ②にて後述しますが、こちらの①の方法でもオペレーションごとに処理の分岐はできる
- 各API関数の引数をいじらなくて良いので、テストの際に使いやすい
- ユニットテストで、Optionを渡したモック用configを使ってClientを生成し、テストで使えば良い
- ②にて後述します
②特定のオペレーション(API呼び出し)時に、Optionとして注入する
こちらは先程のようにconfigではなく、実際のClientからのAPI呼び出し時に関数のOptionとして渡します。
渡す際は、先ほどと同じような「スタックへのmiddlewareのアタッチをラップする関数」をoptions.APIOptions
にセットする関数(ややこしいですね・・・)を、API関数の第3引数に定義します。
// registerDefaultBucketMiddleware registers the defaultBucket middleware with the provided stack. func registerDefaultBucketMiddleware(stack *middleware.Stack) error { // Attach the custom middleware to the beginning of the Initialize step return stack.Initialize.Add(defaultBucket, middleware.Before) } // ... cfg, err := config.LoadDefaultConfig(context.TODO()) if err != nil { // handle error } client := s3.NewFromConfig(cfg) object, err := client.GetObject(context.TODO(), &s3.GetObjectInput{ Key: aws.String("my-key"), }, func(options *s3.Options) { // Register the defaultBucketMiddleware for this operation only options.APIOptions = append(options.APIOptions, registerDefaultBucketMiddleware) })
特徴としては、以下が挙げられます。
- API呼び出し(操作の種類)ごとにミドルウェアの制御がしやすい
- 実際のAPI呼び出し関数(上記
client.GetObject
)の第3引数に直接渡すことになるので、本番コードでのSDKの呼び出し方をmiddlewareを意識したコードに書き換える必要がある「かも」しれない- options関数を呼び出し元からDIしているなら問題はない
後者の点に関して、普段からAPI関数の第3引数にoptions関数を「DI」する環境なのであれば良いですが、option関数を直書きしたり、また「optionをいじることなんてないし・・・」みたいな環境の場合は第3引数を省略して引数を2つしか渡していないような方もいるのではないでしょうか?(はい、私です。)
その場合実際のコードを、「client.GetObject
などの第3引数にoptions関数をセットする」、かつ「実際は中身のある関数を渡さず、テスト環境のみでmiddlewareをうまく渡せるようなDIに書き換える」必要があります。
また前者(API呼び出し(操作の種類)ごとにミドルウェアの制御がしやすい
)に関しては、①のようにconfigでoptionを注入した場合でも、middleware関数内でAPI名(オペレーション名)が取得できるので、それを元に分岐処理を書けばAPIごとに挙動を変えることができます。(次章「モックパターン」の2つ目のパターンでご紹介します)
モックパターン
さて、長くなりましたが、実際にMiddlewareを用いてモックするテストコードを書いてみます。
ここでは、上述の①であるconfigのoptionでmiddlewareを注入します。
※②の方が柔軟性は上がりますが、先述の通り(私の環境では)「テストのためのコードの書き換えの必要があった」ため、①を選びました。
また、3つほどのパターン例に分けて書きました。
①1テストケースの中で1種類のSDKのAPI呼び出ししかしないパターン
全体像
まず一番シンプルな、1テストケースの中で1種類のSDKのAPI呼び出ししかしないケースです。
こんなS3のClientを使用するコードがあり、DeleteBucketメソッドをテストしたいとします。(※テストする意味ある?ってコードですが、サンプルなのでそこは気にしないでもらえたら・・・)
type S3 struct { client *s3.Client } func (s *S3) DeleteBucket(ctx context.Context, bucketName *string) error { input := &s3.DeleteBucketInput{ Bucket: bucketName, } _, err := s.client.DeleteBucket(ctx, input) return err }
これは以下のようにして、「outputを空で返す」「エラーで返す」の2パターンをmiddlewareでモックすることができます。
func TestS3_DeleteBucket(t *testing.T) { type args struct { ctx context.Context bucketName *string withAPIOptionsFunc func(*middleware.Stack) error } cases := []struct { name string args args want error wantErr bool }{ { name: "delete bucket successfully", args: args{ ctx: context.Background(), bucketName: aws.String("test"), withAPIOptionsFunc: func(stack *middleware.Stack) error { return stack.Finalize.Add( middleware.FinalizeMiddlewareFunc( "DeleteBucketMock", func(context.Context, middleware.FinalizeInput, middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { return middleware.FinalizeOutput{ Result: &s3.DeleteBucketOutput{}, }, middleware.Metadata{}, nil }, ), middleware.Before, ) }, }, want: nil, wantErr: false, }, { name: "delete bucket failure", args: args{ ctx: context.Background(), bucketName: aws.String("test"), withAPIOptionsFunc: func(stack *middleware.Stack) error { return stack.Finalize.Add( middleware.FinalizeMiddlewareFunc( "DeleteBucketErrorMock", func(context.Context, middleware.FinalizeInput, middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { return middleware.FinalizeOutput{ Result: nil, }, middleware.Metadata{}, fmt.Errorf("DeleteBucketError") }, ), middleware.Before, ) }, }, want: fmt.Errorf("operation error S3: DeleteBucket, DeleteBucketError"), wantErr: true, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { cfg, err := config.LoadDefaultConfig( tt.args.ctx, config.WithRegion("ap-northeast-1"), config.WithAPIOptions([]func(*middleware.Stack) error{tt.args.withAPIOptionsFunc}), ) if err != nil { t.Fatal(err) } client := s3.NewFromConfig(cfg) s3Client := NewS3(client) err = s3Client.DeleteBucket(tt.args.ctx, tt.args.bucketName) if (err != nil) != tt.wantErr { t.Errorf("error = %#v, wantErr %#v", err, tt.wantErr) return } if tt.wantErr && err.Error() != tt.want.Error() { t.Errorf("err = %#v, want %#v", err, tt.want) } }) } }
解説
まずmiddleware関数のconfigへの渡し方ですが、各argsのwithAPIOptionsFunc
にmiddleware関数を定義し、実際のテストコードで以下のようにLoadDefaultConfig
の第3引数にwithAPIOptionsFunc
をラップしたconfig.WithAPIOptionsを渡して、configにセットします。
cfg, err := config.LoadDefaultConfig( tt.args.ctx, config.WithRegion("ap-northeast-1"), config.WithAPIOptions([]func(*middleware.Stack) error{tt.args.withAPIOptionsFunc}), )
また、以下が「outputを空で返す」モックのwithAPIOptionsFunc
になります。
FinalizeMiddlewareFuncというように、Finalizeステップにてレスポンスの値をいじるようなmiddlewareとなっています。
withAPIOptionsFunc: func(stack *middleware.Stack) error { return stack.Finalize.Add( middleware.FinalizeMiddlewareFunc( "DeleteBucketMock", func(context.Context, middleware.FinalizeInput, middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { return middleware.FinalizeOutput{ Result: &s3.DeleteBucketOutput{}, // <- ★ここ!! }, middleware.Metadata{}, nil }, ), middleware.Before, ) },
Result: &s3.DeleteBucketOutput{},
の部分で、細かいoutputをカスタマイズできるので、各キー・バリューをいじりたい方はここを変更してください。
APIが「エラーを返す」モックにしたい場合は、以下のようにエラーインスタンスを返してあげればOKです。
func(context.Context, middleware.FinalizeInput, middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { return middleware.FinalizeOutput{ Result: nil, }, middleware.Metadata{}, fmt.Errorf("DeleteBucketError") // <- ★ここ!! },
②1テストで複数のAPIを呼び出すパターン
全体像
次は、1テストケースで複数のAPI呼び出しを行うパターンです。
今回は、CloudFormationのClientを使うクラスで、1関数の中でDescribeStacks
とDeleteStack
を呼ぶような処理だとします。
type CloudFormation struct { client *cloudformation.Client waiter *cloudformation.StackDeleteCompleteWaiter } func (c *CloudFormation) DeleteStack(ctx context.Context, stackName *string) error { // ...(省略) output, err := c.client.DescribeStacks(ctx, input) if err != nil { return err } // ...(省略) if _, err := c.client.DeleteStack(ctx, input); err != nil { return err } // ...(省略) }
※今回はwithAPIOptionsFunc
のみ記載します。
withAPIOptionsFunc: func(stack *middleware.Stack) error { return stack.Finalize.Add( middleware.FinalizeMiddlewareFunc( "DeleteStackOrDescribeStacksForWaiterMock", func(ctx context.Context, input middleware.FinalizeInput, handler middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { operationName := awsMiddleware.GetOperationName(ctx) if operationName == "DeleteStack" { return middleware.FinalizeOutput{ Result: &cloudformation.DeleteStackOutput{}, }, middleware.Metadata{}, nil } if operationName == "DescribeStacks" { return middleware.FinalizeOutput{ Result: &cloudformation.DescribeStacksOutput{ Stacks: []types.Stack{ { StackName: aws.String("StackName"), StackStatus: "DELETE_COMPLETE", }, }, }, }, middleware.Metadata{}, nil } return middleware.FinalizeOutput{}, middleware.Metadata{}, nil }, ), middleware.Before, ) },
解説
先ほどと同じくFinalizeMiddlewareFunc
を使用して、その中でawsMiddleware.GetOperationName(ctx)
という関数を使用しています。
AWS SDK for Go V2では呼び出し時にcontextの中にOperationName(呼び出しAPI名)を入れる処理が内部で実行されているため、この関数によってどのAPI呼び出しなのかという情報を取り出すことができます。
これによってDeleteStack
が呼ばれた時、DescribeStacks
が呼ばれた時とで、それぞれで返すOutputの内容を変更することができます。
operationName := awsMiddleware.GetOperationName(ctx) // <- ★ここでAPI名を取得 if operationName == "DeleteStack" { // <- ★分岐 return middleware.FinalizeOutput{ Result: &cloudformation.DeleteStackOutput{}, }, middleware.Metadata{}, nil } if operationName == "DescribeStacks" { // <- ★分岐 return middleware.FinalizeOutput{ Result: &cloudformation.DescribeStacksOutput{ Stacks: []types.Stack{ { StackName: aws.String("StackName"), StackStatus: "DELETE_COMPLETE", }, }, }, }, middleware.Metadata{}, nil }
これによって、1テストケースで複数のAPI呼び出しを行うパターンのモックをmiddlewareで行うことができました。
③APIの引数によってモックの挙動を変えたいパターン
全体像
最後は、API呼び出しの引数(params)によってモックの挙動を変えたいパターンです。
例えばList系のAPIで、Marker
指定がない場合はNextMarker
を返し、Marker
指定がある場合はNextMarker
を返さない、ようにしたい場合です。
※List系のAPIでは、一定のレスポンスサイズを超えた場合はNextMarker
やNextToken
を返し、それを次のリクエストに指定すると続きのデータを取得できるような仕組みがよく使われます。
簡単にテストを書いているとNextMarker
を返さないケースのみでしかテストを書かなかったりもしますが、ここではNextMarker
を返すケースと返さないケースで挙動を変えるようなmiddlewareにします。
そして今回は、先程までのwithAPIOptionsFunc
とは別に、もう一つ関数が登場します。さらに、Finalize以外にInitializeステップも登場します。
まず解説の前に全体像です。
※今回元のコードは省略し、テストファイルから記載します。
- 別関数
type markerKeyForIam struct{} func getNextMarkerForIamInitialize( ctx context.Context, in middleware.InitializeInput, next middleware.InitializeHandler, ) ( out middleware.InitializeOutput, metadata middleware.Metadata, err error, ) { switch v := in.Parameters.(type) { case *iam.ListAttachedRolePoliciesInput: ctx = middleware.WithStackValue(ctx, markerKeyForIam{}, v.Marker) } return next.HandleInitialize(ctx, in) }
- withAPIOptionsFunc以下
withAPIOptionsFunc: func(stack *middleware.Stack) error { err := stack.Initialize.Add( middleware.InitializeMiddlewareFunc( "GetNextMarker", getNextMarkerForIamInitialize, // ★先ほど定義した関数 ), middleware.Before, ) if err != nil { return err } err = stack.Finalize.Add( middleware.FinalizeMiddlewareFunc( "ListAttachedRolePoliciesWithNextMarkerMock", func(ctx context.Context, input middleware.FinalizeInput, handler middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) { marker := middleware.GetStackValue(ctx, markerKeyForIam{}).(*string) var nextMarker *string var attachedPolicies []types.AttachedPolicy if marker == nil { nextMarker = aws.String("NextMarker") // ★NextMarkerをOutputで返すため attachedPolicies = []types.AttachedPolicy{ { PolicyArn: aws.String("PolicyArn1"), PolicyName: aws.String("PolicyName1"), }, } return middleware.FinalizeOutput{ Result: &iam.ListAttachedRolePoliciesOutput{ Marker: nextMarker, AttachedPolicies: attachedPolicies, }, }, middleware.Metadata{}, nil } else { attachedPolicies = []types.AttachedPolicy{ { PolicyArn: aws.String("PolicyArn2"), PolicyName: aws.String("PolicyName2"), }, } return middleware.FinalizeOutput{ Result: &iam.ListAttachedRolePoliciesOutput{ Marker: nextMarker, // ★NextMarkerをnilのまま返す AttachedPolicies: attachedPolicies, }, }, middleware.Metadata{}, nil } }, ), middleware.Before, ) return err },
解説
繰り返しですが、今までのパターン例ではFinalize
ステップのみのmiddlewareを使用しましたが、今回はInitialize
ステップが登場します。
Finalize
ステップは主にレスポンスをカスタマイズする目的でしたが、今回のようにパラメータ(つまり引数)によってモックの挙動を変えたい場合はAPI呼び出し時のパラメータの取得が必要であり、API呼び出し時のパラメータが取得できるのはInitialize
ステップであるためです。
また、Initialize
ステップで取得したパラメータをもとにFinalize
ステップでレスポンスをモックするため、Initializeステップから取得した情報を次以降のステップへ渡す必要があります。
それは、"github.com/aws/smithy-go/middleware"のWithStackValue(ctx, key, value)という関数を使ってContextに情報を格納しておき、Finalize
ステップでGetStackValueという関数でContextから取得することでステップ間での情報の伝播を行うことができます。
そのためInitialize
ステップのmiddlewareに使用する関数は以下のようになります。(これは各テストケースで使い回すため、テストfunction外で定義しています。)
type markerKeyForIam struct{} // ★Contextに渡す値のキーとして使う func getNextMarkerForIamInitialize( ctx context.Context, in middleware.InitializeInput, next middleware.InitializeHandler, ) ( out middleware.InitializeOutput, metadata middleware.Metadata, err error, ) { switch v := in.Parameters.(type) { case *iam.ListAttachedRolePoliciesInput: // 特定のAPI呼び出しの時 ctx = middleware.WithStackValue(ctx, markerKeyForIam{}, v.Marker) // ★ここでContextにパラメータ情報を格納 } return next.HandleInitialize(ctx, in) }
そしてwithAPIOptionsFunc
ですが、上記の関数をInitializeMiddlewareFunc
でラップし、stack.Initialize.Add
によってmiddlewareをスタックへアタッチします。
withAPIOptionsFunc: func(stack *middleware.Stack) error { err := stack.Initialize.Add( middleware.InitializeMiddlewareFunc( "GetNextMarker", getNextMarkerForIamInitialize, // ★先ほど定義した関数 ), middleware.Before, ) if err != nil { return err }
その後FinalizeMiddlewareFunc
関数を通してFinalizeステップでパラメータを取得し、それをもとに返すレスポンスの内容を変えています。
err = stack.Finalize.Add( middleware.FinalizeMiddlewareFunc( "ListAttachedRolePoliciesWithNextMarkerMock", func(ctx context.Context, input middleware.FinalizeInput, handler middleware.FinalizeHandler) (middleware.FinalizeOutput, middleware.Metadata, error) {
ここでまず、Initialize
ステップでContextに格納したパラメータ情報を取得します。
marker := middleware.GetStackValue(ctx, markerKeyForIam{}).(*string)
そして、これをもとにレスポンスのNextMarker
の挙動を変えるロジックを書いていきます。
パラメータでレスポンスを変える仕組みとして、1回目の呼び出しではMarker
指定はない(前にAPIを呼んでいないのでNextMarker
をもらいようがない)ため、「Marker
指定がない場合はNextMarker
を返してもう一度APIを叩かせる」ような挙動にしています。
2回目の呼び出し時は必ず1回目に返されたNextMarker
をMarker
に指定するため、「Marker
指定がある場合はNextMarker
を返さないようにする」ことで、2パターンの挙動を実現することができます。
(そして2回目のNextMarker
が返らなかった場合はアプリケーション側で処理を終了するのが普通なので、そのままAPI呼び出しが終了するはず。)
var nextMarker *string var attachedPolicies []types.AttachedPolicy if marker == nil { nextMarker = aws.String("NextMarker") // ★NextMarkerをOutputで返すため attachedPolicies = []types.AttachedPolicy{ { PolicyArn: aws.String("PolicyArn1"), PolicyName: aws.String("PolicyName1"), }, } return middleware.FinalizeOutput{ Result: &iam.ListAttachedRolePoliciesOutput{ Marker: nextMarker, AttachedPolicies: attachedPolicies, }, }, middleware.Metadata{}, nil } else { attachedPolicies = []types.AttachedPolicy{ { PolicyArn: aws.String("PolicyArn2"), PolicyName: aws.String("PolicyName2"), }, } return middleware.FinalizeOutput{ Result: &iam.ListAttachedRolePoliciesOutput{ Marker: nextMarker, // ★NextMarkerをnilのまま返す AttachedPolicies: attachedPolicies, }, }, middleware.Metadata{}, nil }
このようにして、APIの引数によってモックの挙動を変えるモックを実現することができます。
実例
実際にSDKのミドルウェアを用いてモックしているテストケースですが、OSSで公開しているCloudFormationスタック強制削除ツール「delstack」の「pkg/client」パッケージでのテストケースにて実際に上記パターンで使用しているので、良ければご覧下さい。
参考資料
このミドルウェアを用いたモック手法の存在を知った記事です。大変参考にさせて頂きました。
関連記事:AWS SDK for Go v2のSDK呼び出しのリトライパターン集
AWS SDK for Go v2関連で、SDK呼び出しをリトライする方法のパターン集の記事も書いています。よければぜひご覧ください。
最後に
他言語だとinterfaceを使用せずに簡単にAWS SDKをモックできますが、Goでも一応できるんです、という記事でした。