gRPCのシナリオテスト用コードを生成するprotocプラグインを作ってみた

これは [FJCT Advent Calendar 2018](https://qiita.com/advent-calendar/2018/fjct) の15日目の記事です。 14日目は @foobaron の [データセンタ間ネットワーク接続技術：EVPN-VXLANのActive-Active構成を試しました](https://tech.fjct.fujitsu.com/entry/advent-calendar/2018/evpn-vxlan) でした。

FJCT でエンジニアをしている @YoshidaY です。

最近は社内でも [Protocol Buffers](https://developers.google.com/protocol-buffers/) + [gRPC](https://grpc.io/) が使われるようになってきました。
Protocol BuffersやgRPCの詳細はここでは書きませんがわかりやすい記事が沢山あります。

- [What is gRPC?](http://www.grpc.io/docs/guides/)
- [gRPCって何？](https://qiita.com/oohira/items/63b5ccb2bf1a913659d6)
- [REST APIの設計で消耗している感じたときのgRPC入門](https://qiita.com/disc99/items/cfca50a32240284578bb)
- [ProtocolBuffersについて調べてみた](https://qiita.com/aiueo4u/items/54dc5dd8c4772253634c)

そうなってくると、特定の順序でgRPCのメソッドを呼び出して、レスポンスが期待する結果になるかどうかシナリオテストをしたくなるようなことが多くなってくると思います。
シナリオテストをするにあたっては、大抵の場合テストシナリオは何パターンも用意し、テストシナリオ自体はメソッドを呼び出したり戻り値が期待する結果となるか確認する処理などとは独立させて、データ駆動でテストできる状態にしておきたいのではないでしょうか。
とはいえ、このような仕組みを実装するのは面倒ですし、どのプロジェクトでも使えるように抽象化されたライブラリを作るのも手間がかかりますね。
なので、用途によっては外部のライブラリ等を使ったりもすると思います。
例えば弊社のいくつかのプロジェクトでは、WebAPIでデータ駆動のシナリオテストをする際、弊社の誇るエンジニアの [tily](https://github.com/tily) さんが開発した [barthes](https://github.com/tily/barthes) を使っていたりもします。

同じように、gRPCでもデータ駆動でシナリオテストをしたいのですが、そういった用途の便利なライブラリやprotocプラグインを探してみたところなかなか見つかりませんでした。

ではどうするかというと、残された選択肢は自分で実装するしかありません。
そしてせっかくProtocol Buffersを使っているのだから、いい感じにprotobufスキーマを読み取ってprotocでテスト用のコードを生成したいですね。

というわけで今回は、gRPCのシナリオテスト用コードを生成するprotocプラグインを作ってみたので、それについて書こうと思います。

## 概要

今回作ったのは [protoc-gen-stest](https://github.com/yoshd/protoc-gen-stest) というprotocプラグインです。

protocは簡単に言うと、protobufスキーマから対応するクラスや構造体などを生成するコンパイラで、様々な言語に対応しています。
そのprotocのプラグインの実装方法は極めてシンプルで、protocはprotobufスキーマを読み取った情報を外部プログラムの標準入力に渡し、外部プログラムは出力したい内容を標準出力するだけです。

protocプラグインの実装方法は [protocプラグインの書き方](https://qiita.com/yugui/items/87d00d77dee159e74886) がわかりやすいです。

protocプラグインの有名な例としては、 [grpc-gateway](https://github.com/grpc-ecosystem/grpc-gateway) や [protoc-gen-doc](https://github.com/pseudomuto/protoc-gen-doc) などがあります。
protobufスキーマからgRPCのgolang用ソースコードを生成する [protoc-gen-go](https://github.com/golang/protobuf/tree/master/protoc-gen-go) もprotocプラグインです。

`protoc-gen-stest` で何ができるのかというと、protobufスキーマの情報から、gRPCのシナリオテスト用のソースコードを生成することができます。(Unary RPCのみ)
このプラグインで生成されたコードを使用することで、テストシナリオをJSONで定義してあげて、ほんの少しのgolangコードを書くだけでデータ駆動(JSON)でgRPCのシナリオテストができます。


## 使い方

### インストール

プラグインのインストールは下記のように `go get` でできます。

```shell
go get -u github.com/yoshd/protoc-gen-stest
```

### 呼び出しかた

プラグインの呼び出しは下記のようにprotoc実行時に `--stest_out=<output dir>` を指定します。
PATHの通ったところに置いておけば、 `--plugin=path/to/protoc-gen-stest` は省略可能です。

```shell
protoc -I. --plugin=path/to/protoc-gen-stest --stest_out=. your.proto
```

### サンプル

次のようなprotobufスキーマが定義されているとします。
これは、Sampleというサービスに、HelloとByeという2つのgPRCメソッドを定義している例です。
それぞれのメソッドのリクエストで渡す型とレスポンスの型が `message` で定義されています。

```protobuf:sample.proto
syntax = "proto3";

option go_package = "pb";

service Sample {
    rpc Hello (HelloRequest) returns (HelloResponse) {
    }
    rpc Bye (ByeRequest) returns (ByeResponse) {
    }
}

message HelloRequest {
    string req_msg = 1;
}
message HelloResponse {
    string res_msg = 1;
}
message ByeRequest {
    string req_msg = 1;
}
message ByeResponse {
    string res_msg = 1;
}
```

このprotobufスキーマをもとに実装されたgRPCサーバーをシナリオテストしようと思います。
ちなみに、今回の例で使用する上記のprotobufスキーマやソースコードは [protoc-gen-stestのexamples](https://github.com/yoshd/protoc-gen-stest/tree/master/examples) にあります。

#### プラグインを呼び出してソースコードを生成する

gRPCサーバーは立っている想定として、シナリオテスト用のソースコードを生成します。
protoc-gen-stestで生成されるコードは、 [protoc-gen-go](https://github.com/golang/protobuf/tree/master/protoc-gen-go) で生成されるコードに依存しているので同時に生成してください。

```shell
protoc -I. --plugin=path/to/protoc-gen-stest --go_out=plugins=grpc:pb --stest_out=pb sample.proto
```

#### テストシナリオをJSONで定義する

下記のようなJSONを定義します。
この例は、次のような順序でgRPCメソッドを呼び出します。

`Hello` -> `Bye` -> `Bye`

```json:sample.json
[
    {
        "action": "Hello",
        "request": {
            "req_msg": "Hello!"
        },
        "expected_response": {
            "res_msg": "Hello!"
        },
        "loop": 2,
        "sleep": 3,
        "success_rule": "once"
    },
    {
        "action": "Bye",
        "request": {
            "req_msg": "Bye!"
        },
        "expected_response": {
            "res_msg": "Bye!"
        },
        "loop": 3,
        "sleep": 1,
        "success_rule": "all"
    },
    {
        "action": "Bye",
        "request": {
            "req_msg": "error"
        },
        "error_expectation": true,
        "expected_error_code": 3
    }
]
```

どういうテストをしているのかというと、次のようなことをしています。

1. `Hello` メソッドを `loop` 回実行するまでに、レスポンスが1度でも期待するレスポンス( `expected_response` )になれば成功
2. `Bye` メソッドを `loop` 回実行し、レスポンスが全て期待するレスポンス( `expected_response` )になれば成功
3. `Bye` メソッドを実行し、期待するgRPCのエラーコード( `expected_error_code` )になれば成功

JSONの各フィールドの意味は、次の通りです。

|フィールド           |意味               |値 |
|-------------------|-------------------|---|
|action             |gRPCメソッド名を文字列で指定 |文字列|
|request            |gRPCメソッドの引数をJSONで指定 |JSON<br>※フィールドはprotoc-gen-goで生成されたコードのstructのJSONタグを使用する|
|expected_response  |期待するgRPCメソッドの戻り値を指定<br>error_expectationがtrueの場合は指定しなくて良い|JSON<br>※フィールドはprotoc-gen-goで生成されたコードのstructのJSONタグを使用する|
|loop               |指定した回数だけgRPCメソッドを繰り返し実行する|数値<br>※デフォルト値は1|
|sleep              |指定した秒数だけgRPCメソッドを呼び出す前にsleepする|数値<br>※デフォルト値は0|
|success_rule       |テストを成功とみなすためのルールを指定|allかonceを指定<br>※allはloop回全ての戻り値が期待する結果となれば成功とみなす<br>※onceはloop中1度でも期待する戻り値となれば成功とみなす|
|error_expectation  |エラーをレスポンス期待するかどうか|真偽値<br>※trueの場合はexpected_error_codeが必須で、expected_responseは不要になる|
|expected_error_code|期待するgRPCエラーコード|数値<br>※エラーコードは https://godoc.org/google.golang.org/grpc/codes を参照|

`request`　と `expected_response`　の指定の仕方は、 `protoc-gen-go` で生成されたコードのstructのJSONタグを使用します。
例えば、protobufで下記のmessageが定義されている場合、

```protobuf
message HelloRequest {
    string req_msg = 1;
}
```

生成されるgolangのstructは下記のようになり、JSONのタグが付いています。

```go
type HelloRequest struct {
	ReqMsg string `protobuf:"bytes,1,opt,name=req_msg,json=reqMsg" json:"req_msg,omitempty"`
}
```

これをJSONにすると下記のようになるので、それを `request` や `expected_response` に入れてください。

```json
{
    "req_msg": "Hello!"
}
```

#### テストを実行するgolangソースコードを書く

実際にgRPCメソッドを呼び出すには、gRPCサーバーの接続先やCredentialなどの情報を入れてテストを走らせる必要があります。
（本当はこの辺もう少しいい感じにしたいですが）

生成したコードの使い方は、 `NewTestClient` の引数にgRPCクライアントを渡し、TestClientの `RunGRPCTest` を呼び出します。

簡単な例としては下記のようになります。
デフォルトでは期待するレスポンスと実際のレスポンスを、 `reflect.DeepEqual` で比較します。

```go
package examples

import (
	"testing"

	"github.com/yoshd/protoc-gen-stest/examples/pb"

	"google.golang.org/grpc"
)

func TestScenario(t *testing.T) {
	target := "localhost:13009"
	client, _ := grpc.Dial(target, grpc.WithInsecure())
    defer client.Close()

	sampleClient := pb.NewSampleClient(client)
	testClient := pb.NewTestClient(sampleClient)
	testClient.RunGRPCTest(
		t,
		"scenario/sample.json",
		nil,
	)
}
```


ただ、場合によっては `reflect.DeepEqual` ではなく、他の方法で期待するレスポンスと実際のレスポンスを比較したり、フィールドの一部だけを比較したかったり、あるいは実際のレスポンスをログ出力したかったりもすると思います。
その場合は下記のように、gRPCメソッドごとにどうやって比較するかを定義した関数をmapに入れて `RunGRPCTest` メソッドに渡すこともできます。

```go:scenario_test.go
package examples

import (
	"errors"
	"testing"

	"github.com/yoshd/protoc-gen-stest/examples/pb"

	"google.golang.org/grpc"
)

var responseCompareFuncMap = map[string]*func(expectedResponse, response interface{}) error{}

func TestScenario(t *testing.T) {
	target := "localhost:13009"
	client, _ := grpc.Dial(target, grpc.WithInsecure())
	defer client.Close()
	sampleClient := pb.NewSampleClient(client)
	testClient := pb.NewTestClient(sampleClient)
	testClient.RunGRPCTest(
		t,
		"scenario/sample.json",
		responseCompareFuncMap,
	)
}

func setUp() {
	helloResponseCompareFunc := func(expectedResponse, response interface{}) error {
		if expectedResponse == nil || response == nil {
			return nil
		}
		er := expectedResponse.(pb.HelloResponse)
		r := response.(pb.HelloResponse)
		if er.ResMsg != r.ResMsg {
			return errors.New("The actual response of the Hello was not equal to the expected response")
		}
		return nil
	}
	responseCompareFuncMap["Hello"] = &helloResponseCompareFunc
	byeResponseCompareFunc := func(expectedResponse, response interface{}) error {
		if expectedResponse == nil || response == nil {
			return nil
		}
		er := expectedResponse.(pb.ByeResponse)
		r := response.(pb.ByeResponse)
		if er.ResMsg != r.ResMsg {
			return errors.New("The actual response of the Bye was not equal to the expected response")
		}
		return nil
	}
	responseCompareFuncMap["Bye"] = &byeResponseCompareFunc
}

func TestMain(m *testing.M) {
	setUp()
	m.Run()
}
```

`RunGRPCTest`　の引数は以下のようになっています。
`RunGRPCTest(t *testing.T, jsonPath string, compareFuncMap map[string]*func(expectedResponse, response interface{}) error)`

`jsonPath` にはテストシナリオのJSONのファイルパスを渡し、 `compareFuncMap` にgRPCメソッドごとにどうやって比較するかを定義した関数のmapを入れます。
`compareFuncMap` のkeyには、gRPCメソッド名の文字列を指定し、valueに関数のポインタを渡します。
`compareFuncMap` の関数の引数は期待するレスポンスと実際のレスポンスが渡されます。
戻り値は、比較した結果期待通りではなかった場合の `error` を返すようにしてください。
ただ、ちょっと面倒なのは期待するレスポンスと実際のレスポンスが `interface{}` で渡される点です。
なので、この関数内でそれぞれgRPCメソッドのレスポンス型にキャストした上で比較する必要があります。 (ここは改善したい点)


#### テストの実行

テストは `go test` で実行します。

```shell
go test -v scenario_test.go
```

これでJSONで定義したgRPCのテストシナリオを実行することができます。
JSONのファイルパスの渡し方を工夫すれば、golangのソースコードは全く修正せず、テストシナリオのJSONを追加するだけで、好きなだけデータ駆動でシナリオテストをすることができます。

## 次回予告

16日目は @alice02 の[Data-driven NIFCLOUD SDKについて](https://qiita.com/advent-calendar/2018/fjct) です。