ペロリでエンジニアをしているDeNAの平山と申します。Goの経験は約1年で、主に広告配信サーバーの開発に活用していました。
このエントリでは、これまで実際にGoのテストをどう書いてきたかや、モックの活用について書きます。1
Goのテスティングフレームワーク
Goには標準のテスティングフレームワークとしてtestingパッケージが付属しています。標準のtestingパッケージは最低限の機能のみが提供されています。
実際にテストにおける問題
上記の通り、シンプルさを追求するというポリシーはあるものの、実際の開発においては、やはり標準のtestingパッケージの機能だけでは使い勝手がよくない、というのが現実です。
- Assertionがない
- テスティングヘルパーがほとんどない
- テストスイートの仕組みがない
- Setup/TearDownの機構がない
また、ユニットテストにおいてはモックオブジェクトなどを通じたテストデータの取り扱いも必須ですが、同様に標準のテスティングフレームワークではそれもないので取り扱いに考慮が必要です。
問題解決のヒント
以上の課題についてどう対処すればよいか考えていたところ、以下のエントリにたどり着きました。
Getting a handle on testing and mocking in Golang
英語ですが内容はそれほど難解でもないです。要点を列挙すると、
- CIとユニットテストについてのテストインフラ構築についての考察
- モックオブジェクト作成手法
- イケてるテスティングフレームワーク
- コードカバレッジについて
というトピックから成っています。基本的には、上記のエントリに従う形で、テストインフラを構築していきました。2
gocheck パッケージ
Goの外部のテスティングフレームワークにはいくつかありますが、前出のエントリでは Gocheck というライブラリがオススメされていました。
特徴は以下のとおり。
- 標準のテスティングフレームワークと互換
- テストスイートのサポート
- テストを関数ではなくstructへのメソッドとして定義。3
- fixtureのサポート
-
go test
のオプションとしてのログ出力 - 選択的なテストの起動
実際の開発においてもgocheckを採用することで、標準のテスティングフレームワークに準じつつ、膨大になりがちなテストコードを整理しつつ書きました。
実際のファイル分割
パッケージごとに all_test.go
というファイルを置き、この中で各テストスイートのstructを指定していくことで、テストスイートの管理をシンプルに保ちました。
package xxx_test
import (
"testing"
. "gopkg.in/check.v1"
)
func TestPackage(t *testing.T) {
TestingT(t)
}
var _ = Suite(&LogicSuite{})
var _ = Suite(&CacheSuite{})
package xxx_test
import (
. "gopkg.in/check.v1"
)
type LogicSuite struct{}
func (s *LogicSuite) SetUpSuite(c *C) {
// テストスイート全体での初期化処理を記述する
}
func (s *LogicSuite) SetUpTest(c *C) {
// 個々のテストごとに実行される初期化時の共通処理を記述する
}
func (s *LogicSuite) TearDownSuite(c *C) {
// テストスイート全体での終了後処理を記述する
}
func (s *LogicSuite) TearDownTest(c *C) {
// 個々のテストごとに実行される終了後の共通処理を記述する
}
func (s *LogicSuite) TestSomeProcess(c *C) {
// 実際に実施されるテストを記述
}
モックオブジェクトの活用
前出のエントリでは、いくつかのパターンにおけるモックオブジェクトの作成手法について触れられています。
特にgomockを利用したモックオブジェクト作成については、以下のエントリが詳しいです:
Go Mockでインタフェースのモックを作ってテストする #golang
モックオブジェクト作成における課題
インタフェースを定義しておくことで、上記の通り自動的にモックオブジェクトを作成することは容易です。しかし実際には、モックオブジェクトの挙動を定義するのに EXPECT()
でデータを返すよう記述する必要があったりするので、単純なテストデータを返して欲しいだけの場合などは、割に合わず使いづらい場合があります。
また、モックオブジェクトの場合には、テストによってエラーを返したり、タイムアウトをさせたかったり、また書き込まれたデータを後から参照したい場合など、柔軟に挙動を定義したい場合もあります。そのような場合は自動モックオブジェクト作成では対応できません。
手動モックオブジェクト
実際の開発ではDI的な手法で、モックオブジェクトを手動で作成しています。
例として、memcacheなどのストレージに非同期書き込みする例を示します。
package mycache
// モック化対象のオブジェクト。ここではmemcachedなどに対しキャッシュのGet/Setを取り扱う
type CacheHandler interface {
Get(string) (int, error)
Set(string, int) error
}
// 現在有効なCacheHandlerオブジェクト
var cacheHandler = &defaultCacheHandler{}
// CacheHandlerオブジェクトを登録する。受け取るのはインターフェース。
func RegisterCacheHandler(c CacheHandler) {
cacheHandler = c
}
// デフォルトのCacheHandlerオブジェクトに戻す
func ClearCacheHandler() {
cacheHandler = &defaultCacheHandler{}
}
// 現在有効なCacheHandlerオブジェクトを返す。アプリケーションは必ずこれを呼び出すようにする
func CurrentCacheHandler() CacheHandler {
return cacheHandler
}
// 以下、デフォルトのCacheHandlerオブジェクトの実装
type defaultCacheHandler struct{}
func (*defaultCacheHandler) Get(key string) (val int, err error) {
// memcachedなりから実際にGetする
}
func (*defaultCacheHandler) Set(key string, val int) (err error) {
// memcachedなりに実際にSetする
}
上記がmemcachedなどのストレージに対する読み書きを実行するコードです。
実装は defaultCacheHandler
にあります。
CacheHandler
というインターフェースでキャッシュの実装を抽象化しています。そして、 RegisterCacheHandler
、ClearCacheHandler
でそれぞれ同様のインターフェースの登録、再初期化と、CurrentCacheHandler
で現在のハンドラを返すようにしています。
package mycache
import (
"time"
)
// memcachedのフリして保存される先のハッシュ
var _data = make[string]int
type CacheHandlerMock struct {
DataWriteDuration time.Duration // 擬似書き込み遅延時間
DataWriteChan chan int // データ書き込みが成功なら1を書き込む
}
func (m *CacheHandlerMock) Get(key string) (val int, err error) {
return _data[key], nil
}
// 遅延しつつデータ書き込みする
func (m *CacheHandlerMock) Set(key string, val int) (err error) {
time.Sleep(m.DataWriteDuration)
_data[key] = val
if m.DataWriteChan != nil {
m.DataWriteChan <- 1 // 終了通知
}
}
// テスト時にデータの中身をいつでも参照できるように
func DumpCacheHandlerMock() map[string]int {
return _data
}
// モックデータの初期化。該当するテストのSetUpTest()などで呼び出す
func ResetCacheHandlerMock() {
_data[key] = make[string]int
}
上記がテストに使われるモックオブジェクトです。与えられたキーと値は通常のハッシュオブジェクトに格納しています。
合わせて、書き込みが完了したら DataWriteChan
フィールドに1を送信するようにしています。これにより、通常のコードはシンプルに保ったまま、テストの方ではデータ書き込みがあったら検出できるようにしています。
また、DumpCacheHandlerMock()
で格納されたハッシュをそのまま読み出せるようにすることで、テスト側でデータの検証をしやすくしたり、 ResetCacheHandlerMock()
でデータを容易に初期化できるようにしています。
以降はCacheHandlerを通じてキャッシュを実装する関数の例です:
package mycache
import(
"fmt"
)
func AsyncSet(key string, input int) (err error) {
var h = CurrentCacheHandler()
// 非同期書き込みされる例
go func() {
time.Sleep(10 * time.Millisecond) // 他にいろんな処理があって書き込みに時間がかかるとする…
h.Set(key, input)
}
}
サンプルとして非同期書き込みを行う例として AsyncSet
という関数を実装しています。この関数は、goroutineでいくらか重い処理を行いつつ、キャッシュに引数で与えられたキーと値をセットするものです。
CurrentCacheHandler()
を通じてハンドラオブジェクトを取得し、それを通じて値をセットするようになっています。
このとおり実装側はシンプルなコードになります。
package mycache_test
import (
"mycache"
"testing"
"time"
. "gopkg.in/check.v1"
)
func Test(t *testing.T) { TestingT(t) }
type CacheSuite struct{
cacheHandlerMock *mycache.CacheHandlerMock
}
var _ = Suite(&CacheSuite{})
func (s *CacheSuite) SetUpSuite(c *C) {
// モックアップを作成してRegister
s.cacheHandlerMock = &mycache.CacheHandlerMock{
DataWriteDuration: 10 * time.Millisecond,
DataWriteChan: make(chan int),
}
mycache.RegisterCacheHandler(cacheHandlerMock)
}
func (s *CacheSuite) SetUpTest(c *C) {
// モックのデータを初期化
mycache.ResetCacheHandlerMock()
}
func (s *CacheSuite) TestAsyncSet(c *C) {
// 正常
AsyncSet("foo", 10)
var timeout = time.After(50 * time.Millisecond) // ちょっと長めに待つ
select {
case <-timeout:
c.Fatal("action is not completed.")
case <- s.cacheHandlerMock.DataWriteChan // 終わったら知らせてもらえるはず
}
var mockdata = DumpCacheHandlerMock()
c.Assert(mockdata["foo"], Equals, 10)
}
上記がテスト用のコードです。 SetUpSuite()
でモックアップオブジェクトを構築して、使用するキャッシュハンドラとして登録しています。
TestAsyncSet
で前出の AsyncSet
を呼び出して実際にキャッシュされるかテストしています。モックオブジェクトに書き込み完了のチャンネルを持たせて、それを待つようにしたことで、書き込み完了を待って値の検証を行えるようにしています。
このように、インターフェースを活用してモックオブジェクトを作成することで、基本部分のコードを簡潔に保ちつつ、テストしたい対象のロジックをテストすることができます。
まとめ
gocheckを使うことで、シンプルさを保ちつつ、Goのテスティングフレームワークを拡充できます。
また、テストで必要となるモックオブジェクトの構築方法について、ライブラリを使う方法と、自前でモックオブジェクトを構築して活用する方法について触れました。
以上、実際の開発の参考になりましたら幸いです!