最近周りでProtocol Buffersの話題をよく聞くようになった。

ということは、そろそろ人類はprotocのプラグインを書きたくなる時代がやってくるはず。

そのとき、世の中に生み出されるプラグインの品質が少しでも高くなればと思い、以前protoc-gen-gohttpというプラグイン作ったときにテストも書いたので、その知見を書こうと思う。

プラグイン自体は@yuguiさんの「protocプラグインの書き方」がとても参考になったのでそちらを参照すると良いかもしれない。

TL;DR Link to heading

経緯(読み飛ばし可) 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関数だけを実行してくれるようになる。

このコードにより以下のようにテスト内で自分自身をプラグインとして実行ができるようにしている。

  1. テストコマンドによりバイナリとしてビルドされる
  2. 通常のテストとして実行される
  3. RUN_AS_PROTOC_GEN_GOはまだ設定されていないのでinit関数は無視される
  4. テスト内でprotoc関数が実行される
  5. protoc関数内でテストバイナリが実行ファイルとして指定される
  6. RUN_AS_PROTOC_GEN_GOをセットする
  7. コマンドを実行する
  8. 5で環境変数をセットしたためコマンド実行時にテストバイナリはmain関数を実行する

TestGolden関数 Link to heading

テストの実体が書かれているのがTestGolden関数になる。

やっていることは以下のように単純になっている。

  1. ioutil.TempDirで作業ディレクトリを作成する
  2. testdataディレクトリにあるprotoファイルを取得する
  3. 取得したprotoファイルを使ってprotocコマンドを実行する
    • 出力先は作業ディレクトリ
  4. 作業ディレクトリ内のファイルとtestdataディレクトリにある*.pb.goファイルを比較する

この関数が実行されることで、プラグインが期待される出力をしているかどうかを判定している。

所感 Link to heading

自分自身を実行ファイルとしたり、init内部でmain関数を呼び出していたりと、Goらしくゴリ押しとスマートの中間ぐらいで実現されていて読んでいて非常に面白かった。

それほど長いコードでもない上に、コマンドを実行しないといけない(もしくはそのほうが早い)テストであれば応用が効く仕組みのため、一読して覚えておくと良いかもしれない。