2019年12月26日木曜日

go-workers でワーカーのメイン処理をテストする方法を考えてみた

概要

go-workers のメイン処理は実行される関数を workers.Process に渡すことで実行されます
ワーカー内で使われている構造体などは別途テストすれば良いのですがワーカー全体のテストはいろいろな外部リソース (DB や外部の HTTP サーバーなど) に依存しているケースもあり難しいです
今回は指定したワーカーのメインの処理全体をテストする方法をリファクタリングしながら考えてみました

環境

  • macOS 10.15.2
  • go 1.12.9
    • go-workers
    • redigo
    • kitlog
  • 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() {
}

この FakeRedisRedisInterface を実装しているため 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 でモックありのテストをする場合にはほぼ必須のパターンなので慣れておくことをオススメします

0 件のコメント:

コメントを投稿