Go + gRPCのプロジェクトにシナリオテストをいつの日か導入するための考察
gRPCのプロジェクトにe2eテストを導入するためのツールは何がいいのか興味本位で調べたのでその備忘録です。結論だけ先に書くとrunnというOSSが良さそうで、機会があったら採用したいなと思いました!
モチベーション
開発者レベルで作成できる単体テストはそれなりに書いてきたつもりだが、e2eテストやシナリオテストと呼ばれるような複数のAPIが連鎖的に呼ばれるような処理のテストを書いたことがなく、機会があったらやってみたいと思っていた。Rest APIの場合は、Postmanというツールが有名で、JavaScriptでテストケースを記述しシナリオをコードで管理して実行でき、CLIで実行もできるのでCIにも組み込みやすく採用しているようなケースがそれなりにありそうだったので、似たような感じでgRPCでもシナリオベースのテストは書けるのかな?という疑問をずっといだいていたため今回調査ついでに記事にしました。
対象読者
- 普段gRPCの開発をしている人
- gRPCのプロジェクトにシナリオテストを導入したい人
- テストについて考えるのが好きな人
- テスト書くのが好きな人
ツールの候補
めちゃくちゃ個人的にツールに求めていることとしては以下のような感じです。
- gRPCのシナリオテストを実行したい
- なるべくシンプルにシナリオが書ける方がいい
- CIにのせることも考えたいのでCLIで実行できるようにしたい
- シナリオはコードで管理したい
上記の条件を満たすツールに絞って探してみました。
Postman
Postmanは昔調べたことがあったのだけど、その時はgRPCの対応はされたばかりでベータリリースくらいだったのですが、執筆時点で正式リリースされているようで軽く触ってみました。PostmanといえばAPIクライアントとして手軽にGUIでAPIテストができるのとJavaScriptでテストスクリプトを書ける点が利点だと思ってるのでその2点について触ってみた感想です。
gRPCクライアントとしての機能
普通にかなり使いやすかったです。エンドポイントを指定するだけで実行できるサービスの一覧が選択できますし、添付の画像のようにパラメーターの指定も容易ですし、レスポンスもJson形式で見やすいです。認証情報やMetadataの指定も当然できるのでGUIのgRPCクライアントとしてはかなりいいなと思いました。
gRPCのテストをスクリプトで書く
添付の画像のようにJavaScriptで書けますが、Postmanでのテスト経験がないのでどう書いて良いのかまったくわからん状態だったので同じような方は最初にドキュメントを参照しましょう。1つのサービスに対してのテストは書けそうだったんですが複数のサービスを跨いだテストの書き方がまだよくわからず、Postmanに慣れてないと操作やAPI、テストの書き方などそれなりに学習コストがかかりそうな気がして手軽さにかけるなという感想。(あくまで個人的な感想です。)
Datadog
なんかDatadogでe2eテスト書けるよみたいなのをどこかで見た気がしたので調査。使えそうなのがヘルスチェックとブラウザテストが該当しそうだったけどどちらもやりたいここと微妙に違う感じ。
runn
本命。シナリオをyamlファイルで記述することができるGo製のOSS。
下記の記事が使用者目線でとてもよく書かれているので詳細は下記の記事をご参照ください。
scenarigo
実のところgRPCにおけるシナリオテストで検索して一番最初に知ったのはこちらのscenarigoです。社内向けに一人で作成されたと聞いてすごいなぁと漠然と思った気がします。上述のrunnもこちらのscenarigoを参考にされている部分も多いようだったのですがrunnの方が日本語の情報が多くとっつきやすかったので今回はrunnを使用してみることにしました。使ってないのであれですがrunnでやりたいことの大部分はおそらくscenarigoでも実現可能だと思うので、次の機会でscenarigoも触ってみたいなと思います。
runnでgRPCのシナリオテストを書いてみる
ということで先に挙げた候補の中からrunnを採用して実際に使ってみました。
install
Macであればbrewなどでもインストールできますが、今回はgo installしました。
go install github.com/k1LoW/runn/cmd/runn@latest
runn --version
> runn version 0.67.0
grpcurlからランブックを作成する
yamlファイルを一から作成することもできるようですがgrpcurlからランブックを作成することができます。ランブックとはrunnがyamlファイルの中身をRunbook(操作手順書)
と呼んでいるのでそのままランブックと呼んでいます。
以下のようにrunn new
の後にgrpcurlのコマンドを続けることでランブックの内容が出力されます。
runn new -- grpcurl --d '{"name": "World"}' localhost:8080 hello.GreetingService.Hello
desc: Generated by `runn new`
runners:
greq: grpc://localhost:8080
steps:
- greq:
hello.GreetingService.Hello:
message:
name: World
ランブックの実行とテスト
--and-run
オプションを指定することでテストの取得と実行までできます。
runn new --and-run --grpc-no-tls -- grpcurl -d '{"name": "World"}' localhost:8080
hello.GreetingService/Hello
desc: Generated by `runn new`
runners:
greq: grpc://localhost:8080
steps:
- greq:
hello.GreetingService/Hello:
message:
name: World
test: |
current.res.headers['content-type'][0] == "application/grpc"
&& compare(current.res.message, {"message":"Hello, World!"})
&& current.res.status == 0
ファイルの出力
--out
オプションを指定することでファイルに出力することができます。
runn new --and-run --grpc-no-tls --out ./runbooks/runbook.yml -- grpcurl -d '{"name": "World"}' localhost:8080 hello.GreetingService/Hello
複数ステップの実行
desc: ログインしてHello Worldする
runners:
# gRPCランナーを指定
greq: grpc://localhost:8080
vars:
username: yamanaka
password: pass
steps:
-
desc: Step.0 ログインする
greq:
hello.GreetingService/Login:
message:
name: "{{ vars.username }}"
pass: "{{ vars.password }}"
test: |
current.res.headers['content-type'][0] == "application/grpc"
&& compare(current.res.message, {"id":"U_0001", "name":"yamanaka"})
&& current.res.status == 0
-
desc: Step.1 ログインしたユーザー名でハローする
greq:
hello.GreetingService/Hello:
message:
# 前段のステップの実行結果を使用することができる
name: "{{ previous.res.message.name }}"
test: |
current.res.headers['content-type'][0] == "application/grpc"
&& compare(current.res.message, {"message":"Hello, yamanaka!"})
&& current.res.status == 0
上記のように複数のgRPCのステップ実行がかなり直感的に書ける。previous.res.message
とすることで前回のステップの結果にアクセスすることができ、他にもさまざまなアクセスの仕方がある。
テストコードとして実行
runnはCLIツールでもありGoモジュールでもあるので以下のようにテストコードからrunnを実行することもできます。テストを作成する前にモジュールをインストールしておきます。
go get github.com/k1LoW/runn
func TestHello(t *testing.T) {
// gRPCサーバーの起動
addr := "127.0.0.1:8080"
l, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
s := grpc.NewServer()
hellopb.RegisterGreetingServiceServer(s, service.NewHelloService())
reflection.Register(s)
go func() {
s.Serve(l)
}()
t.Cleanup(func() {
s.GracefulStop()
})
// ランブックの読み込み
ctx := context.Background()
opts := []runn.Option{
runn.T(t),
runn.Runner("greq", fmt.Sprintf("grpc://%s", addr)),
}
o, err := runn.Load("runbooks/*.yml", opts...)
if err != nil {
t.Fatal(err)
}
// runnの実行
if err := o.RunN(ctx); err != nil {
t.Fatal(err)
}
}
テストコードから実行することでランブックの前後に処理を挟んだりできるのでテストデータを作成してからランブックの実行したりといった使用ができるのかなと思ってます。
実際にプロジェクトに導入することを考える
テスト用の環境をみなさんがどう用意しているのか気になるところではあるけど個人的にぱっと思いついた以下の3パターンくらいについて考えてみた。
1. CLIでシナリオ実行 + テスト用のgRPCサーバーインスタンス + テスト用DB
yamlファイルでランブック(シナリオ)を作成する。CI環境で実行することを考えるとCLIコマンドで実行するだけだけどgRPCサーバーの向き先とDBの向き先をテスト用に用意する必要がある。シンプルにテスト用のDBを別途用意して、事前にテストデータを投入し、テスト用DBに向き先が向いているテスト用のインスタンスを用意しておけば実現できる。ただ、テスト用のDBの中のデータが常に増え続けていくことになりそうなのでテストの安定実行と諸々の環境費用がかかりそうだなという感想。
2. テストコードでシナリオ実行 + テスト用DB
CLIからの実行だとテストの前後で処理を挟めないのでテストコードからシナリオを実行する方法。コードからの実行なので別途テスト用のインスタンスは用意しなくていい。DBのみテスト用で用意して、テスト時にそこに向くようにする。テスト用のDBを用意する必要があるので1同様データの管理が課題になりそう。
3. テストコードでシナリオ実行 + テスト用DBをコンテナで用意
テスト用のDBをコンテナで用意することでテスト用のDBを管理する必要がなくなる。コンテナはCI実行環境でdockerを起動するかtestcontainersのようなライブラリの使用で可能。
テストデータはコンテナ作成時に必要なデータを全部投入する(マスターデータなど)とテストコードを書くのは楽になるがコンテナ作成時に毎回データ投入するのでテストの全体的な実行時間が長くなってしまうかもしれない。一方、テストデータはテスト1ケース(1シナリオ)実行する度に必要な分だけ作成し、実行後に削除すると完全にDBをクリーンに保ったうえで必要最低限のデータの作成にとどめられるかもしれない。ただ、テストの前処理で必要なデータを毎回作成するのが大変そう、というか大変だと思う。
シナリオテストを誰が作成して誰が実行・管理するかなどにもよると思うがもし、シナリオテストを作成、管理するのが開発チームと別のQAチームだったりするなら、1のような完全にyamlファイルで完結する方がいいのかもしれない。
逆に開発チーム主導ならテストコードで管理できた方が楽かもしれないし、個人的にはテスト実行時にはDBはまっさらであって欲しいので4でやりたい。
まとめ
- gRPCのシナリオテストを導入するためのライブラリ候補はいろいろあるがrunnはyamlファイルでシナリオを作成することができ、日本語の情報が多くて導入しやすそう
- 実際にプロジェクトへの導入は組織やチームの体制などを考えて適切な方法で導入するのが良さそう
- 個人的にはgRPCにシナリオテストを導入するならrunnとDBコンテナを使用してテストデータはテスト実行前に作成し、実行後に適切に削除することでDBをクリーンに保つ
実際に導入したわけではないのでgRPCにシナリオテストを導入した方がいればぜひコメントなどでぜひご意見ください。今回は以上です!
Discussion