AWS SDK for Go V2 をモックしてユニットテストをする
2021-03-09Go の練習のために 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 好きなので。
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 のモック化、ユニットテストについて何か反応があると嬉しいなーと思ってます。
comments powered by Disqus