AWS SDK for Go v2のAPIをモックして単体テストを実行する

2023.04.30

しばたです。

前の記事でAWS SDK for Go v2を使ったツールを作った話をしましたが、このツールのテストを書くにあたり結構ハマったので共有したいと思います。

Developer Guide

AWS SDK for Go v2のAPIをモックする方法自体は以下のDeveloper Guideにふつうに載っています。

後から改めて見る分には「せやな。」って感じで理解できるのですが、最初にこのドキュメントを見た際は

  • 私のGo言語に対する理解が不足していた *1
  • AWS SDK for Go v1とv2でやり方が微妙に異なり、ネット上には両バージョンの手法が混在するため情報の取捨選択に手間取った

という状態でかなり混乱し理解に手間取りました。
モックに必要なことを一つ一つ整理していけばそこまで難いことは無かったと今では思いますが、もう少し丁寧な説明があっても良いのかなと思うところであります。

そんなわけで自分の様な初心者向けに実例を交えた解説を残しておこうと思った次第です。

お題

本記事では簡単な実例を元にモックの作り方と単体テストの例を紹介していきます。
実行環境は以下の通りです。

  • Windows 11 Ver.22H2
  • Go 1.20.3
  • AWS SDK for Go v2 Release 2023-04-27
    • github.com/aws/aws-sdk-go-v2/config v1.18.22
    • github.com/aws/aws-sdk-go-v2/service/ec2 v1.95.0

1. 最初の状態

自作ツールでも使っているEC2インスタンスのPublic DNS名およびPublic IPアドレスを取得する簡単なプログラムを用意しました。

main.go (初期状態)

package main

import (
	"context"
	"errors"
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/ec2"
)

// 素朴な関数
func GetPublicHostName(instanceId string) (string, error) {
	// 素朴なのでAWS Profileも決め打ち
	cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithSharedConfigProfile("your_profile"))
	if err != nil {
		return "", err
	}
	client := ec2.NewFromConfig(cfg)

	// DescribeInstances API を実行して...
	input := &ec2.DescribeInstancesInput{InstanceIds: []string{instanceId}}
	result, err := client.DescribeInstances(context.TODO(), input)
	if err != nil {
		return "", err
	}
	// 最初に見つかった PublicDnsName または PublicIpAddress を返す
	for _, r := range result.Reservations {
		for _, i := range r.Instances {
			hostname := i.PublicDnsName
			if hostname != nil && *hostname != "" {
				return *hostname, nil
			}
			publicIP := i.PublicIpAddress
			if publicIP != nil && *publicIP != "" {
				return *publicIP, nil
			}
		}
	}
	return "", errors.New("no public DNS name or public IP address found")
}

func main() {
	myInstanceId := "i-xxxxxxxxxxxxxxxx"
	result, err := GetPublicHostName(myInstanceId)
	if err != nil {
		fmt.Printf("error : %v\n", err.Error())
		os.Exit(1)
	}
	fmt.Printf("public host name is : %v", result)
}

GetPublicHostNameという名前の関数に対象インスタンスのIDを与えてやるとPublicDnsNameまたはPublicIpAddressを返すだけのシンプルなプログラムです。
サンプルのプログラムなのでAWS Profileの設定やインスタンスIDはハードコードしています。

このプログラムを実行するとこんな感じの結果になります。

2. APIの切り出し

このプログラムではAWS SDK for Go v2の(ec2.Client.)DescribeInstances関数を使いEC2インスタンスの情報を取得しています。

// GetPublicHostName内のこの部分でAWSのAPIを呼んでいる
result, err := client.DescribeInstances(context.TODO(), input)

単体テストのためにこの関数をモックで差し替えたいわけですが、そのためには最初に専用のインターフェースを用意する必要があります。

今回はEC2APIという名前のインターフェースを新規作成してやります。
このインターフェースに差し替えたい関数定義(今回はDescribeInstances)を追加します。

// AWS APIのモック用にインターフェースを切り出す
type EC2API interface {
	// ec2.Client.DescribeInstances() と同じシグニチャにする
	DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
}

追加しただけでは誰も使わないインターフェースがあるだけなので、単体テストの前準備として最初の実装をEC2APIを使う様に修正してやります。

main.go (修正後)

package main

import (
	"context"
	"errors"
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/ec2"
)

// AWS APIのモック用にインターフェースを切り出す
type EC2API interface {
	// ec2.Client.DescribeInstances() と同じシグニチャにする
	DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
}

// APIの実体を生成する関数を追加
func NewAPI() (EC2API, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithSharedConfigProfile("your_profile"))
	if err != nil {
		return nil, err
	}
	return ec2.NewFromConfig(cfg), nil
}

// APIを引数で指定する様に分離
func GetPublicHostName(api EC2API, instanceId string) (string, error) {
	// これで DescribeInstances() の内容を差し替え可能に
	input := &ec2.DescribeInstancesInput{InstanceIds: []string{instanceId}}
	result, err := api.DescribeInstances(context.TODO(), input)
	if err != nil {
		return "", err
	}
	for _, r := range result.Reservations {
		for _, i := range r.Instances {
			hostname := i.PublicDnsName
			if hostname != nil && *hostname != "" {
				return *hostname, nil
			}
			publicIP := i.PublicIpAddress
			if publicIP != nil && *publicIP != "" {
				return *publicIP, nil
			}
		}
	}
	return "", errors.New("no public DNS name or public IP address found")
}

func main() {
	// APIの実体を生成
	api, err := NewAPI()
	if err != nil {
		fmt.Printf("error : %v\n", err.Error())
		os.Exit(1)
	}

	// GetPublicHostName の引数にAPIを追加
	myInstanceId := "i-xxxxxxxxxxxxxxxx"
	result, err := GetPublicHostName(api, myInstanceId)
	if err != nil {
		fmt.Printf("error : %v\n", err.Error())
		os.Exit(1)
	}
	fmt.Printf("public host name is : %v", result)
}

修正のしかたは色々でしょうが、今回はDeveloper Guideの内容と近くするためにGetPublicHostNameapiパラメーターを増やす形にしています。

// 最初の定義
func GetPublicHostName(instanceId string) (string, error) {
    // 省略
    result, err := client.DescribeInstances(context.TODO(), input)
    // 省略
}
// ↓
// APIをパラメーター化
func GetPublicHostName(api EC2API, instanceId string) (string, error) {
    // 省略
    result, err := api.DescribeInstances(context.TODO(), input)
    // 省略
}

apiパラメーターが増えたことで前準備は完了です。
これでこのapiパラメーターにモックを指定してやればAPIの挙動を差し替えることができます。

3. モックの作成

ここからはテストコードの話となります。

まずは差し替えるモックを準備します。
今回はMockAPIと言う名前の構造体と差し替え用のDescribeInstances関数を準備しました。
DescribeInstances関数の中身はテストしたい内容に応じてよしなに実装しますが、今回はシンプルにOutput,Errorフィールドに設定した値を返すだけにしています。

// テスト用のモックを作成
type MockAPI struct {
	// 今回はシンプルに DescribeInstances の実行結果を差し替え可能にする
	Output *ec2.DescribeInstancesOutput
	Error  error
}

// APIの差し替え
func (m *MockAPI) DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
	// 今回は単純に各フィールドで設定した値を返すだけの実装に
	return m.Output, m.Error
}

4. 単体テストの実施

あとはこのMockAPIモックを使ってテストを書いてやるだけです。
今回は3ケース用意して、それぞれモックが返す戻り値を変えてテストしてます。

main_test.go (テストコード)

package main

import (
	"context"
	"testing"

	"github.com/aws/aws-sdk-go-v2/service/ec2"
	"github.com/aws/aws-sdk-go-v2/service/ec2/types"
)

// テスト用のモックを作成
type MockAPI struct {
	// 今回はシンプルに DescribeInstances の実行結果を差し替え可能にする
	Output *ec2.DescribeInstancesOutput
	Error  error
}

// APIの差し替え
func (m *MockAPI) DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
	// 今回は単純に各フィールドで設定した値を返すだけの実装に
	return m.Output, m.Error
}

// テスト実行
func Test_GetPublicHostName(t *testing.T) {
	// モック用の値
	var (
		instanceId    = "i-1234567890"
		publicDNSName = "public.example.com"
		publicIP      = "1.2.3.4"
	)

	// ケース1 : PublicDnsName, PublicIpAddress ともに存在するケース
	var mock = &MockAPI{
		Output: &ec2.DescribeInstancesOutput{
			Reservations: []types.Reservation{{Instances: []types.Instance{{InstanceId: &instanceId, PublicDnsName: &publicDNSName, PublicIpAddress: &publicIP}}}},
		},
		Error: nil,
	}
	// APIをmockに差し替えて実行 : エラー無く "public.example.com" が戻り値になる
	var result, err = GetPublicHostName(mock, instanceId)
	if err != nil {
		t.Errorf("GetPublicHostName関数でエラーが発生 (%v)", err.Error())
	}
	if result != publicDNSName {
		t.Errorf("戻り値が%vでない", publicDNSName)
	}

	// ケース2 : PublicIpAddress だけ存在するケース
	mock = &MockAPI{
		Output: &ec2.DescribeInstancesOutput{
			Reservations: []types.Reservation{{Instances: []types.Instance{{InstanceId: &instanceId, PublicIpAddress: &publicIP}}}},
		},
		Error: nil,
	}
	// APIをmockに差し替えて実行 : エラー無く "1.2.3.4" が戻り値になる
	result, err = GetPublicHostName(mock, instanceId)
	if err != nil {
		t.Errorf("GetPublicHostName関数でエラーが発生 (%v)", err.Error())
	}
	if result != publicIP {
		t.Errorf("戻り値が%vでない", publicIP)
	}

	// ケース3 : PublicDnsName, PublicIpAddress どちらも無いケース (インスタンス停止中に相当)
	mock = &MockAPI{
		Output: &ec2.DescribeInstancesOutput{
			Reservations: []types.Reservation{{Instances: []types.Instance{{InstanceId: &instanceId}}}},
		},
		Error: nil,
	}
	// APIをmockに差し替えて実行 : エラーになる
	_, err = GetPublicHostName(mock, instanceId)
	if err == nil {
		t.Error("GetPublicHostName関数でエラーが発生しない")
	}
}

補足 : AWS SDK for Go v1の場合

AWS SDK for Go v1ではモック用のインターフェースが専用パッケージの形で提供されていました。
例えばEC2の場合はgithub.com/aws/aws-sdk-go/service/ec2/ec2ifaceパッケージにEC2APIインターフェースが定義されています。

このパッケージとインターフェースはAWS SDK for Go v2では無くなりました。
ただ、v2 GA前の初期段階には一時的に存在しており、WEB検索だとGA前バージョンの情報がヒットしてしまうのがけっこうな罠です。私はこのせいでしばらく混乱しました...ご注意ください。

最後に

以上となります。

慣れている人にとってはごく当たり前のことかもしれませんが初心者向けに内容をまとめてみました。
私自身まだGo言語のテストの書き方に慣れておらず、テストコードはもっと良い感じにできると思います。

脚注

  1. 今も足りてませんが...まあ、そこは一旦棚に上げておきます