概要
go-workers のメイン処理は実行される関数を workers.Process
に渡すことで実行されます
ワーカー内で使われている構造体などは別途テストすれば良いのですがワーカー全体のテストはいろいろな外部リソース (DB や外部の HTTP サーバーなど) に依存しているケースもあり難しいです
今回は指定したワーカーのメインの処理全体をテストする方法をリファクタリングしながら考えてみました
環境
- macOS 10.15.2
- go 1.12.9
- redis-server 5.0.5
ファイル構成、成果物
今回作成したソースファイルは以下の通りです
worker.go がワーカーのメイン処理を実装しているソースになります
ワーカーは Redis とロギング機能を使うことを想定して作っています
もちろん外部の HTTP サーバやシェルコマンドなどを扱う場合はそれに合わせたソースファイルを作成すれば OK です
そしてワーカーのテストファイルは worker_test.go になります
.
├── fake_redis.go
├── logger.go
├── main
│ └── main.go
├── redis.go
├── worker.go
└── worker_test.go
1 directory, 6 files
main.go 以外はすべて worker パッケージとして作成します
main.go
main.go は特に何もしていません
ワーカーのメイン処理である関数を workers.Process
を使って指定するくらいです
vim $GOPATH/src/github.com/hawksnowlog/a/worker/main/main.go
package main
import (
"github.com/jrallison/go-workers"
"github.com/hawksnowlog/a/worker"
)
func main() {
workers.Configure(map[string]string{
"server": worker.GetRedisURL(),
"process": "1",
})
// ここにワーカーのメイン処理を指定します
workers.Process("default", worker.Perform, 1)
workers.Run()
}
worker.Perform
がワーカーのメイン処理になります
また基本的にこのファイルは実行するだめだけのファイルなのでテストは不要かなと思います
worker.go
ここにワーカーのメイン処理を実装していきます
設計としてはワーカーが参照する外部リソースは別のファイルで構造体として管理します
そして、それら別ファイルで管理している構造体をワーカー内のメイン処理で使うようにします
vim $GOPATH/src/github.com/hawksnowlog/a/worker/worker.go
package worker
import (
"fmt"
"github.com/jrallison/go-workers"
)
// Worker 構造体は外部リソースを管理する構造体を持っています
// テスト時にモックしたいリソースは Interface として定義します (重要)
type Worker struct {
logger *Logger
Redis RedisInterface
}
// Worker 構造体を生成します
func NewWorker() *Worker {
return &Worker{
logger: NewLogger(),
Redis: NewRedis(),
}
}
// ワーカーのメイン処理になります
// ここで外部リソースにアクセスしたりします
func Perform(msg *workers.Msg) {
mainWorker := NewWorker()
defer mainWorker.Redis.Close()
a, _ := msg.Args().Array()
mainWorker.logger.Info(fmt.Sprintf("%s", a[0]))
err := mainWorker.Redis.Set(msg.Jid())
if err != nil {
mainWorker.logger.Info(err.Error())
return
}
data, err := mainWorker.Redis.Get()
if err != nil {
mainWorker.logger.Info(err.Error())
return
}
mainWorker.logger.Info(string(data))
}
Perform
が main.go でも指定したワーカーのメイン処理になります
あとで紹介しますがこの Perform
関数をうまくテストするための設計になっています
Worker
構造体で外部リソースの構造体を管理しているのがわかります
NewWorker
を呼ぶことで Worker
構造体を初期化できます
redis.go
今回はワーカーが Redis にデータを格納したり取得したりするケースを想定しています
Redis へのアクセスは redis.go で構造体として管理します
また RedisInterface
を定義しておきます
こうすることでテスト時に Redis 用のモックを作成できるようにしておきます (重要)
vim $GOPATH/src/github.com/hawksnowlog/a/worker/redis.go
package worker
import (
"github.com/gomodule/redigo/redis"
"os"
)
// モックするために Interface を定義します
type RedisInterface interface {
Set(data string) error
Get() ([]byte, error)
Close()
}
type Redis struct {
client redis.Conn
}
const keyName = "data"
const defaultRedisURL = "localhost:6379"
func NewRedis() *Redis {
client, _ := redis.Dial("tcp", GetRedisURL())
return &Redis{
client: client,
}
}
func GetRedisURL() string {
if url, ok := os.LookupEnv("REDIS_URL"); ok {
return url
}
return defaultRedisURL
}
func (r *Redis) Set(data string) error {
_, err := r.client.Do("set", keyName, data)
if err != nil {
return err
}
return nil
}
func (r *Redis) Get() ([]byte, error) {
data, err := redis.Bytes(r.client.Do("get", keyName))
if err != nil {
return nil, err
}
return data, nil
}
func (r *Redis) Close() {
r.client.Close()
}
Redis
構造体は RedisInterface
が持つ Get, Set, Close 関数を実装しています
今回はテスト用のアプリなので格納するデータは適当に設定しています
また golang にはすでに Interface から便利なモックを作成することができるツールがたくさんあります (参考)
今回は理解するために自分でモックを作りましたがより柔軟なモックを使いたい場合は素直にツールを使っても良いと思います
fake_redis.go
外部リソースは基本的にモックを作成する必要があります
そのために先程 RedisInterface
を作成しています
モックの作り方は簡単で先程作成した RedisInterface
が持つ関数を実装した構造を作成すれば OK です
vim $GOPATH/src/github.com/hawksnowlog/a/worker/fake_redis.go
package worker
type FakeRedis struct {
}
const FakeJid = "054f5c637bc26dbd7491a758"
func NewFakeRedis() *FakeRedis {
return &FakeRedis{}
}
func (r *FakeRedis) Set(data string) error {
return nil
}
func (r *FakeRedis) Get() ([]byte, error) {
return []byte(FakeJid), nil
}
func (r *FakeRedis) Close() {
}
この FakeRedis
は RedisInterface
を実装しているため Worker
構造体が持つ Redis
フィールドに設定することができます
ワーカーのテストをするときにはこの FakeRedis
を設定することで実際に Redis にアクセスすることなくテストを進めることができます
logger.go
今回はロガーも外部リソースとして管理しています
標準出力に JSON ログを出力する機能を持っています
今回は logger.go のモックは作っていませんがファイルなどにログを出力するようなケースではモックするなりテスト用のログファイルに出力するような工夫が必要かなと思います
またロギングに kitlog を使っていますが特に深い意味はないのでお好きなロギングパッケージを使って OK です
vim $GOPATH/src/github.com/hawksnowlog/a/worker/logger.go
package worker
import (
kitlog "github.com/go-kit/kit/log"
"os"
)
type Logger struct {
logger kitlog.Logger
}
func NewLogger() *Logger {
w := kitlog.NewSyncWriter(os.Stdout)
logger := kitlog.NewJSONLogger(w)
return &Logger{
logger: logger,
}
}
func (l *Logger) Info(msg string) {
l.logger.Log("msg", msg)
}
worker_test.go
ここまで作成できてようやくワーカーのメイン処理である Perform
関数のテストが書けます
今回のテスト作成の方針は「Perform
関数と同じ流れをテストでも実装する」という方針にしたいと思います
本当は worker.Perform
を呼び出してその結果エラーになるかどうかをチェックするべきだと思います
なぜなら Perform
関数の実装が変更される度にテストの実装も修正する必要があるためです
メインが変わればテストを書き換えるという流れ自体はそこまでおかしなことではないと思いますが、個人的にはあまりよろしくないかなとは思っています
また Perform
に渡されるキューイングされたデータもテスト用に手動で作成します
これは go-workers にメッセージデータを作成する関数が用意されているのでそれを使えば OK です
vim $GOPATH/src/github.com/hawksnowlog/a/worker/worker_test.go
package worker
import (
"fmt"
"github.com/jrallison/go-workers"
"testing"
)
func TestWorker_Perform(t *testing.T) {
// キューイングされたメッセージを擬似的に生成
msg, _ := workers.NewMsg(fmt.Sprintf(`{"jid":"%s","args":["1","2"]}`, FakeJid))
mainWorker := NewWorker()
// Redis には実際にアクセスしない FakeRedis を設定
mainWorker.Redis = NewFakeRedis()
// これ以降は worker.Perform と同じ処理を書くことでテストする
defer mainWorker.Redis.Close()
a, _ := msg.Args().Array()
mainWorker.logger.Info(fmt.Sprintf("%s", a[0]))
err := mainWorker.Redis.Set(msg.Jid())
if err != nil {
mainWorker.logger.Info(err.Error())
return
}
data, err := mainWorker.Redis.Get()
if err != nil {
mainWorker.logger.Info(err.Error())
return
}
mainWorker.logger.Info(string(data))
}
これで redis-server が動作していない状態でもモックがあるためテストできるようになります
動作確認
go fmt ./ ./main && go test -v ./
エラーなくテストできると思います
また Redis には実際にデータなどが格納されていないのも確認できると思います
最後に
go-workers でメインの処理となる perform 関数をテストする方法を考えてみました
ポイントは
- ワーカーが使用する外部リソースは別ファイルとして構造体として管理する
- テスト時に外部リソースに実際にアクセスしたくない場合は外部リソースの構造体をモックする
- 外部リソースの構造体をモックするために必ず Interface を用意する
- テスト時にワーカーのフィールドで管理している外部リソースのフィールドにモックを設定する
という感じです
複雑なことをやっているような気がしますが golang のテスト的には自然な流れかなと思います
むしろ golang でモックありのテストをする場合にはほぼ必須のパターンなので慣れておくことをオススメします