26
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Goでテストを書くために、まずinterfaceを設計するということ

Last updated at Posted at 2021-01-17

Goでテストコードを書くことになった場合に

あなたが、勤務先で上長から「テストコードを書いてください」と指示されたとき、
テストが書ける設計になっているでしょうか。

この記事では、S3からファイルを取得して読み取るというユースケースに対して
テストを書くことについて考えていきます。

テストが書きづらいケース

main.go
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を実装した全体コードです。

main.go
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の条件を満たすことができました。

では実際のテストコードをみてみましょう。

テストコード

main_test.go
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))
}

objectGetterMockreadObject()に渡しています。
そのため、テストではS3からファイルの取得について気にする必要はなく、ファイルの読み取りにフォーカスしたテストを書くことができました。

終わりに

interfaceを設計することでテストコードが非常に書きやすくなります。
ぜひ、テストコードを書くことで品質の高いプロダクトを目指してください。

26
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?