Go言語でTestableなWebアプリケーションを目指して | サイバーエージェント 公式エンジニアブログ

はじめまして@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.ThreadSlice
if _, err := t.db.Select(&threads, sql, args...); err != nil {
return nil, err
}
return threads, nil
}

テストは実際にデータベースに接続して行こないます。アサートにstretchr/testifyを使っています。

package dao
import ...
// func TestMain(m *testing.M)で予め初期化しておきます
var (
  dbMap      *gorp.DbMap
threadDao  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 service
import ...
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 handler
import ...

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 で他のパターンも含めて丁寧にまとめられていますのでこちらも合わせて読んでいただければと思います。

 

長くなりましたが最後まで読んでいただきありがとうございました!