AWS SDK for Go V2でinterfaceのモック"無し"でテスト

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をそのまま使ってテストをすることができます。

aws.github.io


しかし、公式の次の章の「Testing」にて、テストはinterfaceを使ってモックすることを推奨して書かれているので、今回ご紹介するMiddlewareが適切なテスト手法かと言うと微妙なところですが、とても便利なので今回まとめてみました。

aws.github.io


仕組み

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として注入する
  • ②特定のオペレーション(API呼び出し)時に、Optionとして注入する


これらは「オプションの注入の仕方・タイミング」という違いになりますが、具体的には以下のような特徴があります。

①(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種類のSDKAPI呼び出ししかしないパターン

全体像

まず一番シンプルな、1テストケースの中で1種類のSDKAPI呼び出ししかしないケースです。

こんな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関数の中でDescribeStacksDeleteStackを呼ぶような処理だとします。

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では、一定のレスポンスサイズを超えた場合はNextMarkerNextTokenを返し、それを次のリクエストに指定すると続きのデータを取得できるような仕組みがよく使われます。

簡単にテストを書いていると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回目に返されたNextMarkerMarkerに指定するため、「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」パッケージでのテストケースにて実際に上記パターンで使用しているので、良ければご覧下さい。

github.com


参考資料

このミドルウェアを用いたモック手法の存在を知った記事です。大変参考にさせて頂きました。

daisuzu.hatenablog.com


関連記事:AWS SDK for Go v2のSDK呼び出しのリトライパターン集

AWS SDK for Go v2関連で、SDK呼び出しをリトライする方法のパターン集の記事も書いています。よければぜひご覧ください。

go-to-k.hatenablog.com


最後に

他言語だとinterfaceを使用せずに簡単にAWS SDKをモックできますが、Goでも一応できるんです、という記事でした。