はじめまして@shohhei1126です!
2016年1月にリリースされたAmebaFRESH!のサーバサイドを担当しております。
ここ1、2年社内でもGo言語を使うプロジェクトが増えてきているのですが(Go Lang in Cyberagent こちらもどうぞ)AmebaFRESH!でもGo言語をメインに使っています。
今回はGo言語のテストへのアプローチについて考えたいと思います。
テストの書きやすさ
Goでは言語レベルでテストをサポートしている(https://golang.org/pkg/testing/#pkg-overview)のでテストを書くという敷居は他の言語より低く感じます。実際これまでのプロジェクトの中で一番テストを書いていますし、やっぱりテストがあると安心感ありますね(・∀・)
Mock化の難しさ
テストの取っ掛かりとしての敷居は低いですがデータアクセスまわりのテストを書く場合は事前に考えておかないと後々問題が起きてきます。
データアクセス部分でよく見る構成はパッケージ内に変数を持つパターンです。
package
model
var db *sql.DB // ココ
func InitDB(dataSourceName string) {
var err error
db, err = sql.Open(
"mysql"
, dataSourceName)
// ...
}
type Channel struct {
Title string
// ...
}
func (c Channel) findById() error {
rows, err := db.Query(
"SELECT * FROM channels"
)
// ...
}
この構成はmodelパッケージ自体のテストはやりやすいですがmock化できない*1のでHandlerやSerivceのテストを書く場合にもデータベースが必要になりテストコードが煩雑になっていきます。また複数パッケージのテストを並行で実行できないためテスト時間が長くなってしまうというデメリットもあります*2。
逆にmodelとhandlerパッケージだけの構成でhandlerのテストを書く必要がないような小さなアプリケーションはこのような構成でいいかもしれません。
*1 ドライバ自体をモック化する方法はあります(https://github.com/erikstmartin/go-testdb)がテストでも実際にデータベースへ接続したほうが余計な罠を踏まずに済みそうなので今回見送りました。
*2 パッケージごとにデータベースを用意するという選択肢はありそうな気がしますがなんかね。。。
interface 使ってモック化する
前述の問題を解決するためにinterfaceを使ってDIするような構成を考えます。
ソースは shohhei1126/bbs-go にあるので細かいところはこちらを御覧ください。
パッケージ構成
ある程度の規模を想定してhandler, service, daoの三層構成にします。modelパッケージはテーブルに対応するstructを定義したものです。
$ tree
├── dao
├── handler
├── main.go
├── model
└── service
...
DAO
interfaceとその実装です。ORMにgorpを使っているので*gorp.DbMapをフィールドに持たせます。またSQLビルダーにsquirrelを使用しています。
package
dao
import
...
type Thread
interface
{
FindList(paging Paging) (model.ThreadSlice, error)
}
type ThreadImpl struct {
db *gorp.DbMap
}
func (t ThreadImpl) FindList(paging Paging) (model.ThreadSlice, error) {sql, args, err := squirrel.Select("*").From("threads"). OrderBy(paging.OrderBy).Limit(paging.Limit).Offset(paging.Offset). ToSql()if err != nil {return nil, err}var threads model.ThreadSliceif _, err := t.db.Select(&threads, sql, args...); err != nil {return nil, err}return threads, nil}
テストは実際にデータベースに接続して行こないます。アサートにstretchr/testifyを使っています。
package daoimport ...// func TestMain(m *testing.M)で予め初期化しておきますvar ( dbMap *gorp.DbMapthreadDao Thread)
func TestThreadFindList(t *testing.T) {
dbMap.TruncateTables()
createdAt := time.Unix(time.Now().Unix(), 0)
updatedAt := createdAt
threads := make(model.ThreadSlice,
10
)
for
i := range threads {
createdAt = createdAt.Add(time.Hour)
updatedAt = updatedAt.Add(-time.Hour)
threads[i].CreatedAt = createdAt
threads[i].UpdatedAt = updatedAt
if
err := dbMap.Insert(&threads[i]); err != nil {
t.Fatal(err)
}
}
tests := []struct {
paging Paging
expected model.ThreadSlice
}{
{
paging: Paging{OrderBy:
"created_at desc"
, Limit:
3
, Offset:
0
},
expected: threads[
7
:].SortBy(func(t1, t2 model.Thread) bool {
return
t1.CreatedAt.After(t2.CreatedAt)
})},
{
paging: Paging{OrderBy:
"updated_at desc"
, Limit:
3
, Offset:
0
},
expected: threads[
0
:
3
],
},
}
for
_, test := range tests {
threads, err := threadDao.FindList(test.paging)
if
err != nil {
t.Fatal(err)
}
assert
.Equal(t, test.expected, threads,
""
)
}
}
mockgenを使ってモック作成
mockgen を使ってモックを作成します。生成したモックはServiceのテストで使用します。
$ cd dao
$ mockgen -
package
dao -destination thread_mock.go -source thread.go
$ cat thread_mock.go
// Automatically generated by MockGen. DO NOT EDIT!
// Source: thread.go
package
dao
import
(
gomock
"github.com/golang/mock/gomock"
model
"github.com/shohhei1126/bbs-go/model"
)
// Mock of Thread interface
type MockThread struct {
ctrl *gomock.Controller
recorder *_MockThreadRecorder
}
Service
ServiceもDAOと同じような作りになります。内部で使うDAOをプロパティとして持たせています。
package serviceimport ...
type Thread interface
{ FindThreads(paging dao.Paging) (model.ThreadSlice, error)}
type ThreadImpl struct { userDao dao.User threadDao dao.Thread}
func (t ThreadImpl) FindThreads(paging dao.Paging) (model.ThreadSlice, error) {
threads, err := t.threadDao.FindList(paging)
if
err != nil {
return
nil, err
}
// ...
}
テストでは
先ほど作ったDAOをモックを使っています。
func TestThreadFindThreads(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
paging := dao.Paging{OrderBy: "updated_at"
, Limit:
3
, Offset:
3
}
threads := model.ThreadSlice{
{Id:
2
, UserId:
12
},
{Id:
3
, UserId:
13
},
{Id:
4
, UserId:
14
},
}
threadDaoMoc := dao.NewMockThread(ctl)
threadDaoMoc.EXPECT().FindList(paging).Return(threads, nil)
//ここで挙動を指定
users := model.UserSlice{
{Id:
12
, Username:
"username 12"
},
{Id:
13
, Username:
"username 13"
},
{Id:
14
, Username:
"username 14"
},
}
userDaoMoc := dao.NewMockUser(ctl)
userDaoMoc.EXPECT().FindByIds([]uint32{
12
,
13
,
14
}).Return(users, nil)
//ここで挙動を指定
threadService := NewThread(userDaoMoc, threadDaoMoc)
actualThreads, err := threadService.FindThreads(paging)
if
err != nil {
t.Fatal(err)
}
assert
.Equal(t,
int
(paging.Limit), len(actualThreads),
""
)
for
_, thread := range actualThreads {
assert
.Equal(t, thread.UserId, thread.User.Id)
}
}
Handlerのテストのため先ほどのDAOと同じようにmockgenでservice.Threadのモックを作っておきます。
Handler
Handlerはモック化する必要が無いのでstrcutにしています。
package handlerimport ...
type Thread struct {
threadService service.Thread
}
func (t Thread) List(ctx context.Context, r *http.Request) response.Response {
limit, err := strconv.ParseInt(r.URL.Query().Get(
"limit"
),
10
,
64
)
if
err != nil {
// ...
}
service.Threadのモックを作成しテストします。
func TestThreadList(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
threadServiceMock := service.NewMockThread(ctl)
threadServiceMock.
EXPECT().
FindThreads(dao.Paging{Limit:
5
, Offset:
0
, OrderBy:
"updated_at desc"
}).
Return(model.ThreadSlice{}, nil).
Times(
1
)
// 一度だけ呼ばれることを確認
threadHandler := NewThread(threadServiceMock)
r := http.Request{}
url, err := url.Parse(
"http://localhost?limit=5&offset=0"
)
if
err != nil {
t.Fatal(err)
}
r.URL = url
threadHandler.List(ctx, &r)
}
}
テスト
データベースへの接続がdaoパッケージのみになるのでまとめてテストを実行することが出来ます。
$ GO15VENDOREXPERIMENT=
1
$ cd $GOPATH/src/github.com/shohhei1126/bbs-go
$ go test $(go list ./... | grep -v vendor)
ok github.com/shohhei1126/bbs-go/dao
0
.106s
ok github.com/shohhei1126/bbs-go/handler
0.012s
ok github.com/shohhei1126/bbs-go/model
0.011s
ok github.com/shohhei1126/bbs-go/service
0
.013s
...
main.go
それぞれの実装クラスのインスタンスを作ってHandlerをGojiのHTTP multiplexerに登録します。
dbm := parseDb(conf.DbMaster)
dbs := parseDb(conf.DbSlave)
dbMMap := model.Init(dbm, log.Logger)
dbSMap := model.Init(dbs, log.Logger)
mux := goji.NewMux()
userDao := dao.NewUser(dbMMap, dbSMap)
threadDao := dao.NewThread(dbMMap, dbSMap)
threadService := service.NewThread(userDao, threadDao)
threadHandler := handler.NewThread(threadService)
mux.HandleFuncC(pat.Get(
"/v1/threads"), wrap(threadHandler.List))
サーバ起動とAPI実行
$ go run main.go &
INFO[
0000
] starting server...
$ curl -XGET
"http://localhost:8080/v1/threads?limit=5&offset=0"
[{
"id"
:
9
,
"title"
:
"i"
,
"body"
:
"i"
,
"createdAt"
:"
20
...
まとめ
ある程度の規模のプロジェクトでもinterfaceとmockgenを使うことでテストが書きやすくテストの実行時間も短くすることが出来ます。もし同じような問題に直面しているのであれば参考にしていただけると幸いです。
またAmebaFRESH!ではマイクロサービスアーキテクチャをとっているので大小様々なマイクロサービスが存在しています。それぞれのマイクロサービスの規模や重要度などによって構成やテストの方針は変わってくるので個別最適した形で開発しています。
最後にデータベースアクセスに関して Practical Persistence in Go: Organising Database Access で他のパターンも含めて丁寧にまとめられていますのでこちらも合わせて読んでいただければと思います。
長くなりましたが最後まで読んでいただきありがとうございました!