これは FJCT Advent Calendar 2018 の15日目の記事です。 14日目は @foobaron の データセンタ間ネットワーク接続技術:EVPN-VXLANのActive-Active構成を試しました でした。
FJCT でエンジニアをしている @YoshidaY です。
最近は社内でも Protocol Buffers + gRPC が使われるようになってきました。
Protocol BuffersやgRPCの詳細はここでは書きませんがわかりやすい記事が沢山あります。
そうなってくると、特定の順序でgRPCのメソッドを呼び出して、レスポンスが期待する結果になるかどうかシナリオテストをしたくなるようなことが多くなってくると思います。
シナリオテストをするにあたっては、大抵の場合テストシナリオは何パターンも用意し、テストシナリオ自体はメソッドを呼び出したり戻り値が期待する結果となるか確認する処理などとは独立させて、データ駆動でテストできる状態にしておきたいのではないでしょうか。
とはいえ、このような仕組みを実装するのは面倒ですし、どのプロジェクトでも使えるように抽象化されたライブラリを作るのも手間がかかりますね。
なので、用途によっては外部のライブラリ等を使ったりもすると思います。
例えば弊社のいくつかのプロジェクトでは、WebAPIでデータ駆動のシナリオテストをする際、弊社の誇るエンジニアの tily さんが開発した barthes を使っていたりもします。
同じように、gRPCでもデータ駆動でシナリオテストをしたいのですが、そういった用途の便利なライブラリやprotocプラグインを探してみたところなかなか見つかりませんでした。
ではどうするかというと、残された選択肢は自分で実装するしかありません。
そしてせっかくProtocol Buffersを使っているのだから、いい感じにprotobufスキーマを読み取ってprotocでテスト用のコードを生成したいですね。
というわけで今回は、gRPCのシナリオテスト用コードを生成するprotocプラグインを作ってみたので、それについて書こうと思います。
概要
今回作ったのは protoc-gen-stest というprotocプラグインです。
protocは簡単に言うと、protobufスキーマから対応するクラスや構造体などを生成するコンパイラで、様々な言語に対応しています。
そのprotocのプラグインの実装方法は極めてシンプルで、protocはprotobufスキーマを読み取った情報を外部プログラムの標準入力に渡し、外部プログラムは出力したい内容を標準出力するだけです。
protocプラグインの実装方法は protocプラグインの書き方 がわかりやすいです。
protocプラグインの有名な例としては、 grpc-gateway や protoc-gen-doc などがあります。
protobufスキーマからgRPCのgolang用ソースコードを生成する protoc-gen-go もprotocプラグインです。
protoc-gen-stest
で何ができるのかというと、protobufスキーマの情報から、gRPCのシナリオテスト用のソースコードを生成することができます。(Unary RPCのみ)
このプラグインで生成されたコードを使用することで、テストシナリオをJSONで定義してあげて、ほんの少しのgolangコードを書くだけでデータ駆動(JSON)でgRPCのシナリオテストができます。
使い方
インストール
プラグインのインストールは下記のように go get
でできます。
go get -u github.com/yoshd/protoc-gen-stest
呼び出しかた
プラグインの呼び出しは下記のようにprotoc実行時に --stest_out=<output dir>
を指定します。
PATHの通ったところに置いておけば、 --plugin=path/to/protoc-gen-stest
は省略可能です。
protoc -I. --plugin=path/to/protoc-gen-stest --stest_out=. your.proto
サンプル
次のようなprotobufスキーマが定義されているとします。
これは、Sampleというサービスに、HelloとByeという2つのgPRCメソッドを定義している例です。
それぞれのメソッドのリクエストで渡す型とレスポンスの型が message
で定義されています。
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 にあります。
プラグインを呼び出してソースコードを生成する
gRPCサーバーは立っている想定として、シナリオテスト用のソースコードを生成します。
protoc-gen-stestで生成されるコードは、 protoc-gen-go で生成されるコードに依存しているので同時に生成してください。
protoc -I. --plugin=path/to/protoc-gen-stest --go_out=plugins=grpc:pb --stest_out=pb sample.proto
テストシナリオをJSONで定義する
下記のようなJSONを定義します。
この例は、次のような順序でgRPCメソッドを呼び出します。
Hello
-> Bye
-> Bye
[
{
"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
}
]
どういうテストをしているのかというと、次のようなことをしています。
-
Hello
メソッドをloop
回実行するまでに、レスポンスが1度でも期待するレスポンス(expected_response
)になれば成功 -
Bye
メソッドをloop
回実行し、レスポンスが全て期待するレスポンス(expected_response
)になれば成功 -
Bye
メソッドを実行し、期待するgRPCのエラーコード(expected_error_code
)になれば成功
JSONの各フィールドの意味は、次の通りです。
フィールド | 意味 | 値 |
---|---|---|
action | gRPCメソッド名を文字列で指定 | 文字列 |
request | gRPCメソッドの引数をJSONで指定 | JSON ※フィールドはprotoc-gen-goで生成されたコードのstructのJSONタグを使用する |
expected_response | 期待するgRPCメソッドの戻り値を指定 error_expectationがtrueの場合は指定しなくて良い |
JSON ※フィールドはprotoc-gen-goで生成されたコードのstructのJSONタグを使用する |
loop | 指定した回数だけgRPCメソッドを繰り返し実行する | 数値 ※デフォルト値は1 |
sleep | 指定した秒数だけgRPCメソッドを呼び出す前にsleepする | 数値 ※デフォルト値は0 |
success_rule | テストを成功とみなすためのルールを指定 | allかonceを指定 ※allはloop回全ての戻り値が期待する結果となれば成功とみなす ※onceはloop中1度でも期待する戻り値となれば成功とみなす |
error_expectation | エラーをレスポンス期待するかどうか | 真偽値 ※trueの場合はexpected_error_codeが必須で、expected_responseは不要になる |
expected_error_code | 期待するgRPCエラーコード | 数値 ※エラーコードは https://godoc.org/google.golang.org/grpc/codes を参照 |
request
と expected_response
の指定の仕方は、 protoc-gen-go
で生成されたコードのstructのJSONタグを使用します。
例えば、protobufで下記のmessageが定義されている場合、
message HelloRequest {
string req_msg = 1;
}
生成されるgolangのstructは下記のようになり、JSONのタグが付いています。
type HelloRequest struct {
ReqMsg string `protobuf:"bytes,1,opt,name=req_msg,json=reqMsg" json:"req_msg,omitempty"`
}
これをJSONにすると下記のようになるので、それを request
や expected_response
に入れてください。
{
"req_msg": "Hello!"
}
テストを実行するgolangソースコードを書く
実際にgRPCメソッドを呼び出すには、gRPCサーバーの接続先やCredentialなどの情報を入れてテストを走らせる必要があります。
(本当はこの辺もう少しいい感じにしたいですが)
生成したコードの使い方は、 NewTestClient
の引数にgRPCクライアントを渡し、TestClientの RunGRPCTest
を呼び出します。
簡単な例としては下記のようになります。
デフォルトでは期待するレスポンスと実際のレスポンスを、 reflect.DeepEqual
で比較します。
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
メソッドに渡すこともできます。
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
で実行します。
go test -v scenario_test.go
これでJSONで定義したgRPCのテストシナリオを実行することができます。
JSONのファイルパスの渡し方を工夫すれば、golangのソースコードは全く修正せず、テストシナリオのJSONを追加するだけで、好きなだけデータ駆動でシナリオテストをすることができます。
次回予告
16日目は @alice02 のData-driven NIFCLOUD SDKについて です。