Goでテストコードを書くことになった場合に
あなたが、勤務先で上長から「テストコードを書いてください」と指示されたとき、
テストが書ける設計になっているでしょうか。
この記事では、S3からファイルを取得して読み取るというユースケースに対して
テストを書くことについて考えていきます。
テストが書きづらいケース
var awssess = newSession()
var s3Client = newS3Client()
const defaultRegion = "ap-northeast-1"
func newSession() *session.Session {
return session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
}
func newS3Client() *s3.S3 {
return s3.New(awssess, &aws.Config{
Region: aws.String(defaultRegion),
})
}
func readObject(bucket, key string) ([]byte, error) {
obj, err := s3Client.GetObject(&s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
defer obj.Body.Close()
res, err := ioutil.ReadAll(obj.Body)
if err != nil {
return nil, err
}
return res, nil
}
func main() {
res, err := readObject("{Your Bucket}", "{Your Key}")
if err != nil {
log.Println(err)
}
log.Println(string(res))
return
}
S3バケットに存在するファイルをlog.Println()
により表示しています。
readObject()
に対してテストコードを書きます。
しかしreadObject()
はS3に直接アクセスする処理が含まれており、外部処理に完全に依存しています。
このままではテストを実行するごとにS3へのアクセスを実行しなくてはならず、現実的ではありません。
ここでまず考えるべきことは、モックを作るということです。
関数を分ける
まず、現状のreadObject()
の問題点は以下の2点が同じ関数に存在することにあります。
- S3からファイルの取得
- ファイルの読み取り
そこで、この2つの処理を別関数で分離します。
func getObject(bucket, key string) (*s3.GetObjectOutput, error) {
obj, err := s3Client.GetObject(&s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
return obj, nil
}
func readObject(bucket, key string) ([]byte, error) {
obj, err := getObject(bucket, key)
if err != nil {
return nil, err
}
defer obj.Body.Close()
res, err := ioutil.ReadAll(obj.Body)
if err != nil {
return nil, err
}
return res, nil
}
これで分離ができました。
しかし、このままでは前述のS3アクセスへの依存は解決できていません。
ここで、interfaceの出番です。
interfaceの実装
結論から述べると、モック化したい関数はinterface
の関数として実装するとよいです。
当記事では、getObject()
をinterface
の関数とします。
そうすることで、getObject()
の中の処理を外から渡すことができます。
正常時はS3へのアクセスを渡し、テスト時はモックを渡すことができるわけです。
以下がinterface
を実装した全体コードです。
package main
import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"io/ioutil"
"log"
)
var awssess = newSession()
var s3Client = newS3Client()
const defaultRegion = "ap-northeast-1"
func newSession() *session.Session {
return session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
}
func newS3Client() *s3.S3 {
return s3.New(awssess, &aws.Config{
Region: aws.String(defaultRegion),
})
}
type objectGetterInterface interface {
getObject() (*s3.GetObjectOutput, error)
}
type objectGetter struct {
Bucket string
Key string
}
func newObjectGetter(bucket, key string) *objectGetter {
return &objectGetter{
Bucket: bucket,
Key: key,
}
}
func (getter *objectGetter) getObject() (*s3.GetObjectOutput, error) {
obj, err := s3Client.GetObject(&s3.GetObjectInput{
Bucket: aws.String(getter.Bucket),
Key: aws.String(getter.Key),
})
if err != nil {
return nil, err
}
return obj, nil
}
func readObject(t objectGetterInterface) ([]byte, error) {
obj, err := t.getObject()
if err != nil {
return nil, err
}
defer obj.Body.Close()
res, err := ioutil.ReadAll(obj.Body)
if err != nil {
return nil, err
}
return res, nil
}
func main() {
t := newObjectGetter("{Your Bucket}", "{Your Key}")
res, err := readObject(t)
if err != nil {
log.Println(err)
}
log.Println(string(res))
return
}
readObject()
の引数に注目してください。
objectGetterInterface
を引数に渡しています。
また、getObject()
はobjectGetter
構造体をレシーバとし、メソッド化しています。
objectGetterInterface
は、以下のメソッドを満たしていることを条件としています。
getObject() (*s3.GetObjectOutput, error)
つまり、objectGetter
構造体はobjectGetterInterface
の条件を満たしているということになります。
では、モックのメソッドについても考えていきます。
同じように、getObject() (*s3.GetObjectOutput, error)
を満たすように実装してみましょう。
type objectGetterMock struct{}
func (m objectGetterMock) getObject() (*s3.GetObjectOutput, error) {
b := ioutil.NopCloser(strings.NewReader("hoge"))
return &s3.GetObjectOutput{
Body: b,
}, nil
}
hoge
という文字列が記録されているファイルが格納されているという想定です。
これで、objectGetterMock
構造体も同様にobjectGetterInterface
の条件を満たすことができました。
では実際のテストコードをみてみましょう。
テストコード
package main
import (
"github.com/aws/aws-sdk-go/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"io/ioutil"
"strings"
"testing"
)
type testSuite struct {
suite.Suite
service *objectGetter
}
func (s *testSuite) SetUpTest() {
s.service.Bucket = "dummy"
s.service.Key = "dummy"
}
func TestExecution(t *testing.T) {
suite.Run(t, new(testSuite))
}
type objectGetterMock struct{}
func (m objectGetterMock) getObject() (*s3.GetObjectOutput, error) {
b := ioutil.NopCloser(strings.NewReader("hoge"))
return &s3.GetObjectOutput{
Body: b,
}, nil
}
func (s *testSuite) Test() {
mock := objectGetterMock{}
res, _ := readObject(mock)
assert.Equal(s.T(), "hoge", string(res))
}
以下に注目してください。
func (s *testSuite) Test() {
mock := objectGetterMock{}
res, _ := readObject(mock)
assert.Equal(s.T(), "hoge", string(res))
}
objectGetterMock
をreadObject()
に渡しています。
そのため、テストではS3からファイルの取得について気にする必要はなく、ファイルの読み取りにフォーカスしたテストを書くことができました。
終わりに
interfaceを設計することでテストコードが非常に書きやすくなります。
ぜひ、テストコードを書くことで品質の高いプロダクトを目指してください。