michimani.net

AWS SDK for Go V2 をモックしてユニットテストをする

2021-03-09

Go の練習のために AWS SDK for Go V2 を使って ACM を簡単に扱えるパッケージ的なものを作っています。その際、ユニットテストをするために SDK をモックする必要があったので、その方法について書きます。

目次

はじめに

AWS SDK for Go V2 では、 V1 のときにあった ***iface 系のパッケージが含まれていないので、 SDK 部分をモックしたい場合は自分で API のインターフェイスを作成する必要があります。この記事では、公式のドキュメントをもとに SDK のモックを作ってユニットテストを実行するところまでの方法について書いています。

AWS SDK for Go のモック化について悩んでいる方の参考になれば良いのですが、逆に書き方おかしいところ等あればコメントいただけると嬉しいです。

goacm

今回題材にするのは goacm というパッケージで、 ACM (AWS Certificate Manager) での操作をラップしたものになってます。プログラムから ACM を操作することってあんまりないと思うんですが、 ACM でできることって限られているので迷子にならないかなと思って選びました。あと、個人的に ACM 好きなので。

michimani/goacm: goacm is a simple package for using AWS Certificate Manager from applications implimented golang.

SDK のモック化とテストについてはこのあと書いていきますが、多分ソースコードを見たほうがわかりやすいので↑の GitHub のほうを見てください。

AWS SDK for Go V2 のモック化とユニットテスト

AWS SDK for Go V1 には ***iface というパッケージ (ACM であれば acmiface) が含まれていて、それをもとに mockgen でモックを簡単に生成することができていました。 V2 でも GA 前のバージョン (ACM であれば v0.24.0 ) までは存在していましたが、それ以降削除されています。

なので、モック化したい API のインターフェイスを作る必要があります。また、内部で SDK を使う関数では、その引数としてサービスのクライアントを受け取るようにすることでテストがしやすくなります。

なお、参考にするのは下記の AWS SDK for Go V2 のドキュメントです。

Unit Testing with the AWS SDK for Go V2 | AWS SDK for Go V2

ユニットテストを見据えた実装

まず、ユニットテストをしやすいような実装についてです。独自で関数を作成する際に、引数にサービスのクライアントを受け取るようにします。例えば、 ARN を指定して Certificate を取得する GetCertificate という関数を作る場合、次のようにします。

// GetCertificate returns the details of the certificate.
func GetCertificate(ctx context.Context, api ACMDescribeCertificateAPI, arn string) (Certificate, error) {
	in := acm.DescribeCertificateInput{
		CertificateArn: aws.String(arn),
	}
	out, err := api.DescribeCertificate(ctx, &in)
	if err != nil {
		return Certificate{}, err
	}

	vMethod := ""
	recordSet := RecordSet{}
	if out.Certificate.DomainValidationOptions != nil {
		vMethod = string(out.Certificate.DomainValidationOptions[0].ValidationMethod)
		if vMethod == string(types.ValidationMethodDns) {
			recordSet.HostedDomainName = aws.ToString(out.Certificate.DomainValidationOptions[0].ValidationDomain)
			recordSet.Name = aws.ToString(out.Certificate.DomainValidationOptions[0].ResourceRecord.Name)
			recordSet.Value = aws.ToString(out.Certificate.DomainValidationOptions[0].ResourceRecord.Value)
			recordSet.Type = string(out.Certificate.DomainValidationOptions[0].ResourceRecord.Type)
		}
	}

	return Certificate{
		Arn:                 arn,
		DomainName:          aws.ToString(out.Certificate.DomainName),
		Status:              string(out.Certificate.Status),
		Type:                string(out.Certificate.Type),
		FailureReason:       string(out.Certificate.FailureReason),
		ValidationMethod:    vMethod,
		ValidationRecordSet: recordSet,
	}, nil
}

引数として Certificate の ARN だけを受け取るのではなく、 ACM のクライアント (API) (と Context) を受け取るようにしています。

諸々の構造体は下記のように定義しています。

// ACMDescribeCertificateAPI is an interface that defines the set of ACM API operations required by the DescribeCertificate function.
type ACMDescribeCertificateAPI interface {
	DescribeCertificate(ctx context.Context, params *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error)
}

// RecordSet is a structure that reopresents a record set for Route 53.
type RecordSet struct {
	HostedDomainName string
	Name             string
	Value            string
	Type             string
	TTL              int64
}

// Certificate is a structure that represents a Certificate.
type Certificate struct {
	Arn                 string
	Region              string
	DomainName          string
	Type                string
	Status              string
	FailureReason       string
	ValidationMethod    string
	ValidationRecordSet RecordSet
}

この関数の場合、内部で ACM の DescribeCertificate() を呼んでいるので、その部分をモック化する必要があります。また、モック化したあとにテストしやすいように、 GetCertificate() では、メソッド DescribeCertificate() を持つインターフェイス ACMDescribeCertificateAPI を受け取るようにしています。

次の項で DescribeCertificate() を持つ ACMDescribeCertificateAPI をモック化していきます。

サービスの API のモック化

まず、モック用の構造体、関数、及びメソッドを次のように定義します。

// MockACMAPI is a struct that represents an ACM client.
type MockACMAPI struct {
	DescribeCertificateAPI MockACMDescribeCertificateAPI
}

// MockACMDescribeCertificateAPI is a type that represents a function that mock ACM's DescribeCertificate.
type MockACMDescribeCertificateAPI func(ctx context.Context, params *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error)

// DescribeCertificate returns a function that mock original of ACM DescribeCertificate.
func (m MockACMAPI) DescribeCertificate(ctx context.Context, params *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
	return m.DescribeCertificateAPI(ctx, params, optFns...)
}

そして、モックの中身を生成する関数を次のように定義します。

// MockACMParams is a structure with the elements needed to generate a mock.
type MockACMParams struct {
	Certificate Certificate
}

// GenerateMockACMAPI return MockACMAPI.
func GenerateMockACMAPI(mockParams []MockACMParams) MockACMAPI {
	return MockACMAPI{
		DescribeCertificateAPI: GenerateMockACMDescribeCertificateAPI(mockParams),
	}
}

// GenerateMockACMDescribeCertificateAPI returns MockACMDescribeCertificateAPI.
func GenerateMockACMDescribeCertificateAPI(mockParams []MockACMParams) MockACMDescribeCertificateAPI {
	return MockACMDescribeCertificateAPI(func(ctx context.Context, params *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
		if params.CertificateArn == nil {
			return nil, errors.New("expect Certificate ARN to not be nil")
		}

		var availableCertificates map[string]*acm.DescribeCertificateOutput = map[string]*acm.DescribeCertificateOutput{}
		for _, mp := range mockParams {
			if mp.Certificate.Arn == "" {
				continue
			}

			dv := types.DomainValidation{
				ValidationMethod: types.ValidationMethod(mp.Certificate.ValidationMethod),
			}
			if mp.Certificate.ValidationMethod == string(types.ValidationMethodDns) {
				dv.DomainName = aws.String(mp.Certificate.DomainName)
				dv.ValidationDomain = aws.String(mp.Certificate.ValidationRecordSet.HostedDomainName)
				dv.ResourceRecord = &types.ResourceRecord{
					Name:  aws.String(mp.Certificate.ValidationRecordSet.Name),
					Value: aws.String(mp.Certificate.ValidationRecordSet.Value),
					Type:  types.RecordType(mp.Certificate.ValidationRecordSet.Type),
				}
			}

			availableCertificates[mp.Certificate.Arn] = &acm.DescribeCertificateOutput{
				Certificate: &types.CertificateDetail{
					CertificateArn:          aws.String(mp.Certificate.Arn),
					DomainName:              aws.String(mp.Certificate.DomainName),
					Status:                  types.CertificateStatus(mp.Certificate.Status),
					Type:                    types.CertificateType(mp.Certificate.Type),
					FailureReason:           types.FailureReason(mp.Certificate.FailureReason),
					DomainValidationOptions: []types.DomainValidation{dv},
				},
			}
		}

		dco := availableCertificates[*params.CertificateArn]
		if dco == nil {
			return nil, fmt.Errorf("certificate arn not found arn: %s", *params.CertificateArn)
		}

		return dco, nil
	})
}

この GenerateMockACMDescribeCertificateAPI() が何をしているかというと、モック生成用のパラメーター MockParams のスライスを受け取って、その分の Certificate を availableCertificates map[string]*acm.DescribeCertificateOutput として保持するような DescribeCertificate のモック MockACMDescribeCertificateAPI を返しています。 (伝わってほしい)

このモックに対して acm.DescribeCertificateInput で ARN を指定すると、 availableCertificates のキーに存在する ARN であればそれに紐づく acm.DescribeCertificateOutput を返してくれます。

GenerateMockACMAPI() では、 ACM の他の API (ListCertificates とか DeleteCertificate) のモックを追加した際に、それらをすべて持つような ACM クライアントのモックを作るためです。ここか goacm のコード 見てもらうとわかりやすいかと思います。

モックを利用したテスト

モックができたので、それを使ったテストを下記のように実装します。

func TestGetCertificate(t *testing.T) {
	ap := []MockACMParams{
		{
			Certificate: Certificate{
				Arn:        "arn:aws:acm:ap-northeast-1:000000000000:certificate/this-is-a-sample-arn",
				DomainName: "test.example.com",
				Status:     string(types.CertificateStatusIssued),
				Type:       string(types.CertificateTypeAmazonIssued),
			},
		},
	}

	cases := []struct {
		name      string
		acmClient func(t *testing.T) MockACMAPI
		arn       string
		wantErr   bool
		expect    Certificate
	}{
		{
			name: "normal",
			acmClient: func(t *testing.T) MockACMAPI {
				return GenerateMockACMAPI(ap)
			},
			arn:     "arn:aws:acm:ap-northeast-1:000000000000:certificate/this-is-a-sample-arn",
			wantErr: false,
			expect: Certificate{
				Arn:           "arn:aws:acm:ap-northeast-1:000000000000:certificate/this-is-a-sample-arn",
				DomainName:    "test.example.com",
				Status:        string(types.CertificateStatusIssued),
				Type:          string(types.CertificateTypeAmazonIssued),
				FailureReason: "",
			},
		},
		{
			name: "notFound",
			acmClient: func(t *testing.T) MockACMAPI {
				return GenerateMockACMAPI(ap)
			},
			arn:     "arn:aws:acm:ap-northeast-1:000000000000:certificate/not-found-arn",
			wantErr: true,
			expect:  Certificate{},
		},
	}

	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			ctx := context.TODO()
			c, err := GetCertificate(ctx, tt.acmClient(t), tt.arn)
			if tt.wantErr {
				assert.Error(t, err)
				return
			}
			assert.NoError(t, err)
			assert.Equal(t, tt.expect, c)
		})
	}
}

テストケースとしては、 指定した ARN で Certificate が取得できること と、 指定した ARN に該当する Certificate が存在しない場合にエラーになること です。

モック用の API を生成するためのパラメーターとして下記を定義して、先ほど実装した GenerateMockACMDescribeCertificateAPI() に渡しています。

ap := []MockACMParams{
  {
    Certificate: Certificate{
      Arn:        "arn:aws:acm:ap-northeast-1:000000000000:certificate/this-is-a-sample-arn",
      DomainName: "test.example.com",
      Status:     string(types.CertificateStatusIssued),
      Type:       string(types.CertificateTypeAmazonIssued),
    },
  },
}

生成したモック API を使って自作の GetCertificate() を実行して、各テストケースを実行しています。

ひとこと

Go の練習のために AWS SDK for Go V2 を使って作ったパッケージのユニットテストをするために SDK をモックする必要があったので、その方法について書きました。

SDK for Go V2 のドキュメントを参考にしたものの、こういう作り方で良いのか?みたいなのは自信がないので、とりあえずやったことを書いてみました。この記事がどれだけの人の目にふれるかわかりませんが、 AWS SDK for Go V2 のモック化、ユニットテストについて何か反応があると嬉しいなーと思ってます。

以上、よっしー (michimani) でした。