less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

GoでAWS APIをモックする

開発中のCLIツールのテストで、AWS API の呼び出しをモックする仕組みを試行錯誤したのでメモ。

github.com

ツールでは 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
}

エラーコードはこの辺りで確認する。

docs.aws.amazon.com