Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
4
Help us understand the problem. What is going on with this article?
@yoshd

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

More than 1 year has passed since last update.

これは 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のシナリオテスト用のソースコードを生成することができます。(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 で定義されています。

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について です。

4
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
yoshd

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
4
Help us understand the problem. What is going on with this article?