出力を入力へ

プログラミングに関する自分が考えた事を中心にまとめます

GoでSDK呼び出しをモックできるコードを書く

AWS SDK for GoなどのSDKを利用する場合のユニットテストの書き方およびプロダクトコードの実装方法が難しい。 AWS公式には Unit Testing with the AWS SDK for Go V2 にて解説があり、モックで差し替えられるようにAPIのインタフェースを利用してテストで差し替えることができるように推奨している。 一方で、このケースにおいてプロダクトコード側をどのように実装すればよいかわからず、またテストも複雑でどのようにテストを増やせばよいかわからなかった。

いろいろと検討して自分の中である程度納得できたのでそのまとめ。とはいえわからないことも沢山あるのでまだまだ検証が必要。 ベースとなるコードは Unit Testing with the AWS SDK for Go V2 から、検討結果のコードはこちらのリポジトリにある。

プロダクトコードの実装

インタフェースの暗黙的な実装

一番最小限の方法。 Goのインタフェースは暗黙的に実装される (implemented implicitly)ので、オブジェクトを代入する変数の型として S3GetObjectAPI 型を宣言すればよい。 これにより変数 apiS3GetObjectAPI 型 (実態は s3.Client)になり、GetObject 関数を呼び出すことができる。また、テスト時にはAWSドキュメント記載の通りモックで差し替えればよい。

func CallGetObjectFromS3Direct() {
    var bucket = "sample-bucket"
    var key = "sample-object-key"

    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        log.Fatal(err)
    }
    var api S3GetObjectAPI = s3.NewFromConfig(cfg)

    objectString, err := GetObjectFromS3(context.TODO(), api, bucket, key)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(objectString))
}

ただし、これでは GetObjectFromS3 関数を呼び出す側はawsに強く依存したままだし、S3GetObjectAPI 型のインスタンスを生成するときにその実装は s3.clientであることを知らないといけない。 単体テスト時にSDK依存部分をモックで差し替えることが目的で、SDKに依存することを隠蔽したいわけではないのでこれで十分かもしれないが、インタフェースで実装は分離できていない。

コンストラクタによる生成の分離

AWS SDKに強く依存すること、インスタンス生成に関する知識が求められることが問題であれば、コンストラクタを使えばよい。これによりインスタンス生成に関する知識を分離できる。 S3GetObjectAPI インタフェース型のインスタンスを実装するための構造体として S3GetObject を定義し、そのコンストラクタとして NewS3GetObject を定義する。 これによりGetObjectFromS3 を呼び出す側もAWS SDKに依存せず、apiの生成に関する知識にも依存しなくなった。

type S3GetObject struct {
    Client S3GetObjectAPI
}

func NewS3GetObject() (*S3GetObject, error) {
    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        return nil, err
    }
    client := s3.NewFromConfig(cfg)

    return &S3GetObject{
        Client: client,
    }, nil
}

func CallGetObjectFromS3Interface() {
    var bucket = "sample-bucket"
    var key = "sample-object-key"

    api, err := NewS3GetObject()
    if err != nil {
        log.Fatal(err)
    }

    objectString, err := GetObjectFromS3(context.TODO(), api.Client, bucket, key)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(objectString))
}

ただし、ユニットテストでAWS APIをモックしたいだけなら過剰に見える。 また、呼び出し側が api.Client を渡す必要があるのも違和感がある。どうすればよいかはわからなかった。

モックを用いたテストの改善

モック定義の分離

Goのテーブルテスト手法 を考えると、オリジナルのテストケースごとにモック関数を定義する方法はわかりにくいし差分を意識することが大変。 モック関数の定義はテーブルの外で行い、各テストケースはこれを参照するだけにしたい。

func TestGetObjectFromS3(t *testing.T) {
    mockClient := func(t *testing.T) S3GetObjectAPI {
        return mockGetObjectAPI(func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
            t.Helper()
            if params.Bucket == nil {
                t.Fatal("expect bucket to not be nil")
            }
            if e, a := "fooBucket", *params.Bucket; e != a {
                t.Errorf("expect %v, got %v", e, a)
            }
            if params.Key == nil {
                t.Fatal("expect key to not be nil")
            }
            if e, a := "barKey", *params.Key; e != a {
                return nil, errors.New("NoSuchKey")
            }

            return &s3.GetObjectOutput{
                Body: ioutil.NopCloser(bytes.NewReader([]byte("this is the body foo bar baz"))),
            }, nil
        })
    }

    cases := []struct {
        name string
        client func(t *testing.T) S3GetObjectAPI
        bucket string
        key string
        expect []byte
    }{
        {
            name: "return content",
            client: mockClient,
            bucket: "fooBucket",
            key:    "barKey",
            expect: []byte("this is the body foo bar baz"),
        },
...

これで、複数のテストケースを追加した場合でも見通しがよくなる。また、テストケースにテスト名(name)を加えることでよりテスト内容をわかりやすくできる。 ただし、テストケースごとに期待する挙動が異なるのでテストケース中に定義した方がよい場合もありそう。上記の例だと比較的汎用的な動作をするモックを作成しているのでテーブル外定義でよさそう。

モック用の構造体の定義

S3GetObjectAPIはインタフェース型として定義されている一方で、mockGetObjectAPIは関数型として定義されている。 個人的にはこの対応関係がわかりにくかった。プロダクトコードとテストコードで対応関係が取れていて欲しい。 (ただし、この方法には課題がある。詳細後述)

type MockGetObject struct {
    MockGetObjectAPI func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
}

func (m *MockGetObject) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
    return m.MockGetObjectAPI(ctx, params, optFns...)
}

func TestGetObjectFromS3(t *testing.T) {
    mockClient := func(t *testing.T) S3GetObjectAPI {
        return &MockGetObject{
            MockGetObjectAPI: func(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
                t.Helper()
...

ポイントとしては2つ。

1つ目はMockGetObjectのレシーバとしてGetObject関数を定義しているところ。この関数内部ではテスト用の MockGetObjectAPIを呼び出しており、MockGetObjectAPIをテスト時にモックで差し替えることでテストに応じた動作を実装することができる。 ただし、テスト時にGetObjectをモックとして直接差し替える方法があればもっとシンプルになる。このように段階を踏む必要が本当にあるのか疑問。

2つ目はMockt生成時に*testing.T を引数とした関数を介しているところ。 テストテーブルにて直接 &MockGetObject{...} を返さず *testing.Tを引数の関数を返すようにしている。これにより MockGetObjectAPI のモック実装時にテストのヘルパー関数を利用できる。 ヘルパー関数が不要であれば直接mockGetObjectのインスタンスを返した方がよいだろうか。

まだよくわかっていないところ

テストで繰り返し定義が必要

MockGetObject構造体定義、GetObject関数定義およびインスタンス生成時のMockGetObjectAPI実装時の合計3回、GetObjectに関する引数を持った型定義が必要。 これが非常に煩雑で面倒に見える。 今回はGetObjectだけだからよいが、GetObject以外の関数もモックが必要になると大量の定義が必要になる。 これをどうにかならないか、もっときれいな実装がないか疑問。 このあたりを解決するのがgomockなどの自動生成ツールなのか、という疑問もありそれは別途検証する。

プロダクトコードとテストコードの対応関係

このブログ記事を書きながら考えを整理していたら、S3GetObject 構造体と MockGetObject 構造体は全然対応できていないことに気が付いた。 S3GetObject 構造体のフィールドとして Clientを利用しているところがたぶん上手く理解できていない。

参考