Go
golang
testing
ProtocolBuffers
gRPC

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

これは 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-gatewayprotoc-gen-doc などがあります。

protobufスキーマからgRPCのgolang用ソースコードを生成する protoc-gen-go もprotocプラグインです。

protoc-gen-stest で何ができるのかというと、protobufスキーマの情報から、gRPCのシナリオテスト用のソースコードを生成することができます。

このプラグインで生成されたコードを使用することで、テストシナリオを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 で定義されています。


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 にあります。


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

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


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
※フィールドは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にすると下記のようになるので、それを requestexpected_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 メソッドに渡すこともできます。


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 で実行します。

go test -v scenario_test.go

これでJSONで定義したgRPCのテストシナリオを実行することができます。

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


次回予告

16日目は @alice02Data-driven NIFCLOUD SDKについて です。