ユニットテストはテスト対象コードに外部依存がない状態で実施するのが理想的です。
外部依存はデータベース参照やWeb APIへのリクエストなど、別コンポーネントとのやりとりを行う箇所で発生しますが、Goではインターフェースを利用してその箇所の実装を差し替えることで、外部依存を除いてテストを実施することができます。
AWS SDK for Goは、AWSの各種リソースをGoのプログラムから扱うためのライブラリです。
内部的にはAWS APIを利用しているため、外部依存を除いてテストをするためには実装を気をつける必要があります。このエントリではその具体的な方法について紹介します。
サンプルコード
以下はステータスがrunning
であるEC2インスタンスのインスタンスIDを表示するプログラムです。
AWS SDK for Goを利用してインスタンス一覧を取得する処理は、main関数から呼び出されるDescribeRunningInstances
で行っています。
package main
import (
"fmt"
"os"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
)
func DescribeRunningInstances() ([]string, error) {
var runningInstances []string
sess := session.Must(session.NewSession())
svc := ec2.New(sess)
input := &ec2.DescribeInstancesInput{}
result, err := svc.DescribeInstances(input)
if err != nil {
return runningInstances, err
}
for _, r := range result.Reservations {
if *r.Instances[0].State.Name == "running" {
runningInstances = append(runningInstances, *r.Instances[0].InstanceId)
}
}
return runningInstances, nil
}
func main() {
instances, err := DescribeRunningInstances()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
for _, instance := range instances {
fmt.Println(instance)
}
os.Exit(0)
}
このプログラムのコアロジックであるDescribeRunningInstances
をテストしたいのですが、そのままですとsvc.DescribeInstances(input)
が外部依存なので、これを何とかする必要があります。
インターフェースを利用する
AWS SDK for Goでは、各種サービスクライアントのモックを作成するためのインターフェースを提供されています。
サンプルプログラムでは"github.com/aws/aws-sdk-go/service/ec2"を利用していますので、その場合は"github.com/aws/aws-sdk-go/service/ec2/ec2iface"が利用できます。
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"ではEC2API
というインターフェースが定義されています。
ec2.EC2
はec2iface.EC2API
インターフェースを実装しているので、サンプルプログラムでec2iface.EC2API
を利用することでテストのときに実装を差し替えられるようにしたいと思います。
package main
import (
"fmt"
"os"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
)
func DescribeRunningInstances(svc ec2iface.EC2API) ([]string, error) {
var runningInstances []string
input := &ec2.DescribeInstancesInput{}
result, err := svc.DescribeInstances(input)
if err != nil {
return runningInstances, err
}
for _, r := range result.Reservations {
if *r.Instances[0].State.Name == "running" {
runningInstances = append(runningInstances, *r.Instances[0].InstanceId)
}
}
return runningInstances, nil
}
func main() {
sess := session.Must(session.NewSession())
svc := ec2.New(sess)
instances, err := DescribeRunningInstances(svc)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
for _, instance := range instances {
fmt.Println(instance)
}
os.Exit(0)
}
まず、実際のEC2サービスクライアントの作成はmain関数で行います。
sess := session.Must(session.NewSession())
svc := ec2.New(sess)
DescribeRunningInstances
では引数でec2iface.EC2API
型の変数を受け取るようにして、それをEC2サービスクライアントとして利用します。
func DescribeRunningInstances(svc ec2iface.EC2API) ([]string, error) {
var runningInstances []string
input := &ec2.DescribeInstancesInput{}
result, err := svc.DescribeInstances(input)
これにより、テストのときはec2iface.EC2API
を実装したモッククライアントを引数で渡せるようになり、実装の差し替えが可能になります。
テスト
package demo2
import (
"reflect"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
)
type mockEC2Svc struct {
ec2iface.EC2API
}
func (m *mockEC2Svc) DescribeInstances(input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) {
return &ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-aaaaaa"), State: &ec2.InstanceState{Name: aws.String("running")}}}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-bbbbbb"), State: &ec2.InstanceState{Name: aws.String("running")}}}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-cccccc"), State: &ec2.InstanceState{Name: aws.String("running")}}}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-dddddd"), State: &ec2.InstanceState{Name: aws.String("stopped")}}}},
},
}, nil
}
func TestDescribeRunningInstances(t *testing.T) {
svc := &mockEC2Svc{}
runningInstance, err := DescribeRunningInstances(svc)
if err != nil {
t.Error(err)
}
expected := []string{
"i-aaaaaa",
"i-bbbbbb",
"i-cccccc",
}
if !reflect.DeepEqual(expected, runningInstance) {
t.Errorf("expected %q to eq %q", expected, runningInstance)
}
}
ec2iface.EC2API
を埋め込んだ構造体mockEC2Svc
を定義します。
type mockEC2Svc struct {
ec2iface.EC2API
}
これでmockEC2Svc
はec2iface.EC2API
インターフェースを実装したことになりますので、DescribeRunningInstances
の引数に渡すことができます。
svc := &mockEC2Svc{}
runningInstance, err := DescribeRunningInstances(svc)
mockEC2Svc
を引数に渡したことで、DescribeRunningInstances
の中で呼び出されているsvc.DescribeInstances
の実装はモックに置き換えられています。
ただし、この時点ではまだ具体的な実装をしていませんので、svc.DescribeInstances
を呼び出しても何もしない状態です。これではテストができませんので、mockEC2Svc
のDescribeInstances
メソッドにテスト用の実装を書いていきます。
今回は4インスタンスのうち3インスタンスのステータスがrunning
である状態を想定したレスポンスを返します。
func (m *mockEC2Svc) DescribeInstances(input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) {
return &ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-aaaaaa"), State: &ec2.InstanceState{Name: aws.String("running")}}}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-bbbbbb"), State: &ec2.InstanceState{Name: aws.String("running")}}}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-cccccc"), State: &ec2.InstanceState{Name: aws.String("running")}}}},
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-dddddd"), State: &ec2.InstanceState{Name: aws.String("stopped")}}}},
},
}, nil
}
あとは通常どおりにテストを書けばOKです。DescribeRunningInstances
はstring型のスライスを返すので、期待される結果をexpected
に定義してreflect.DeepEqual
でDescribeRunningInstances
で返された値と比較します。
expected := []string{
"i-aaaaaa",
"i-bbbbbb",
"i-cccccc",
}
if !reflect.DeepEqual(expected, runningInstance) {
t.Errorf("expected %q to eq %q", expected, runningInstance)
}
まとめ
外部依存の箇所はあらかじめインターフェースを利用した実装にしておくのがよさそうです。