最近周りでProtocol Buffersの話題をよく聞くようになった。
ということは、そろそろ人類はprotocのプラグインを書きたくなる時代がやってくるはず。
そのとき、世の中に生み出されるプラグインの品質が少しでも高くなればと思い、以前protoc-gen-gohttpというプラグイン作ったときにテストも書いたので、その知見を書こうと思う。
プラグイン自体は@yuguiさんの「protocプラグインの書き方」がとても参考になったのでそちらを参照すると良いかもしれない。
TL;DR Link to heading
- 細かいやり方はprotoc-gen-goのgolden_test.goに書いてある
- 自分のプラグインのテストもそれを参考にして書いた
- プラグインの動作テストはテスト内でprotocコマンドを実行して行う
- 実行結果の確認はGolden testingで行う
- Goのコードでprotoファイルをplugin.CodeGeneratorRequestに変換することは(恐らく)できない
経緯(読み飛ばし可) Link to heading
protocのプラグインは標準入力に入ってきたデータをproto.CodeGeneratorRequest
型にUnmarshalしてprotoファイルの情報を取得する。
そのため、最初にテストを書こうとしたときにはGoだけでprotoファイルをproto.CodeGeneratorRequest
に変換する仕組みがあるのかと思っていたが、この記事公開時点ではその仕組みは提供されていないようだった。
有名なプラグインはどうやっているのかと思いprotoc-gen-goのコードを見てみると、Goのコードの中で直接protocコマンドを実行していて、少し面白いテストをしていたので解説しようと思う。
golden_test.goの解説 Link to heading
ファイル名から想像できる通り、いわゆるGolden testingと呼ばれるものになっている。 テストの中からprotocコマンドを直接実行して、その結果の出力ファイルとtestdataディレクトリに入っている期待するファイルとを比較をしてテストしている。
300行ぐらいなので全部読んでもそんなに時間はかからないとは思うが、簡単に解説をしてみようと思う。
コマンド実行部分 Link to heading
以下のコードが直接コマンドを実行する部分。testing.T
構造体とprotocに与える引数を受け取って実際にコマンドを実行する。
func protoc(t *testing.T, args []string) {
cmd := exec.Command("protoc", "--plugin=protoc-gen-go="+os.Args[0])
cmd.Args = append(cmd.Args, args...)
// We set the RUN_AS_PROTOC_GEN_GO environment variable to indicate that
// the subprocess should act as a proto compiler rather than a test.
cmd.Env = append(os.Environ(), "RUN_AS_PROTOC_GEN_GO=1")
out, err := cmd.CombinedOutput()
if len(out) > 0 || err != nil {
t.Log("RUNNING: ", strings.Join(cmd.Args, " "))
}
if len(out) > 0 {
t.Log(string(out))
}
if err != nil {
t.Fatalf("protoc: %v", err)
}
}
実装自体は標準パッケージに含まれるexec.Command
を利用してprotocコマンドを実行しているだけ。
少し特殊な実装として、以下の2つがある。
- protocの実行時引数に
os.Args[0]
を与えている RUN_AS_PROTOC_GEN_GO
という環境変数に1を設定している
1つ目のos.Args[0]
を与えている部分はprotocの引数の仕様で、プラグインのバイナリファイルを指定して実行するための記述になる。
os.Args
の0番目にはテスト実行時にビルドされたバイナリファイルのPATHが入っているため、"--plugin=protoc-gen-go="+os.Args[0]
とすることで、テストのバイナリをprotoc-gen-goプラグインとして実行できる。
2つ目の環境変数は、環境変数名の通り、これがセットされたらそれ以降はテストバイナリ自体がprotoc-gen-goのバイナリのように動作するためにセットしている。 詳細は次のinit関数の項目で解説する。
init関数 Link to heading
golden_test.goのinit関数は以下のように書かれている。
// tests and instead act as protoc-gen-go. This allows the test binary to
// pass itself to protoc.
func init() {
if os.Getenv("RUN_AS_PROTOC_GEN_GO") != "" {
main()
os.Exit(0)
}
}
関数内ではRUN_AS_PROTOC_GEN_GO
環境変数を確認し、それに文字列がセットされていたらmain関数を実行してそのまま終了させている。
のコードがあることで、RUN_AS_PROTOC_GEN_GO
に何かしらがセットされているときにはテストバイナリはmain関数だけを実行してくれるようになる。
このコードにより以下のようにテスト内で自分自身をプラグインとして実行ができるようにしている。
- テストコマンドによりバイナリとしてビルドされる
- 通常のテストとして実行される
RUN_AS_PROTOC_GEN_GO
はまだ設定されていないのでinit関数は無視される- テスト内でprotoc関数が実行される
- protoc関数内でテストバイナリが実行ファイルとして指定される
RUN_AS_PROTOC_GEN_GO
をセットする- コマンドを実行する
- 5で環境変数をセットしたためコマンド実行時にテストバイナリはmain関数を実行する
TestGolden関数 Link to heading
テストの実体が書かれているのがTestGolden関数になる。
やっていることは以下のように単純になっている。
ioutil.TempDir
で作業ディレクトリを作成する- testdataディレクトリにあるprotoファイルを取得する
- 取得したprotoファイルを使ってprotocコマンドを実行する
- 出力先は作業ディレクトリ
- 作業ディレクトリ内のファイルとtestdataディレクトリにある*.pb.goファイルを比較する
この関数が実行されることで、プラグインが期待される出力をしているかどうかを判定している。
所感 Link to heading
自分自身を実行ファイルとしたり、init内部でmain関数を呼び出していたりと、Goらしくゴリ押しとスマートの中間ぐらいで実現されていて読んでいて非常に面白かった。
それほど長いコードでもない上に、コマンドを実行しないといけない(もしくはそのほうが早い)テストであれば応用が効く仕組みのため、一読して覚えておくと良いかもしれない。