gRPC単位の結合テストを書く際、雛形を自動生成してテストを書けるようにした話です。
gRPCのテストでなくても、他の様々なテストに似たような活かし方ができそうと思ったので書いています。
注意書き
テンプレートのライブラリとして text/template
を使うともっとスマートにできることを知ったので、修正予定です。
GoでgRPC単位のテストを書くときの難点
Goで単体テストを書く場合、InteliJやVSCodeのプラグインには、コードの雛形を自動生成してくれる機能があります。
しかしgRPCやAPIなど、リクエスト単位のテストを書く際は、テストファイルを作って、テストを書いて...というステップをすべて自分で行う必要があります。
そのため、自動化できるところは自動化してしまおうと思ったのが今回の試みのきっかけです。
また、Go製のコード自動生成ツールはいくつかあるのですが、今回自動生成するものは割と小さめなコードでパターンがほとんど決まっていたので、お手製で自動生成するコマンドを作成しました。
テストファイルの単位
gRPC単位テストでやりたかったことは、「gRPCを叩いてそのレスポンスをアサートする」というのを、基本的にすべてのAPIに対して1テストケースは用意しておこうというものでした。
テスト対象が関数ではなくgRPCのリクエストになるので、1テストファイル=1種類のgRPCとしました。
例えば、 createBook
というgRPCがあった場合、 create_book_test.go
というファイルを作ります。
その中に、 createBook
gRPCに対する様々なテストケースを書いていく感じです。
gRPCのテストケースごとに共通していた処理
新しいテストファイルを作る際、他のテストファイルでやっている同じような処理を毎回コピペしたり書いたりするのが大変だったのが、自動生成のモチベでした。
gRPC単位のテストを書き始める際、共通していたのが主に下記の処理です。
- テストファイルを作成する
- テストを実行する関数を作成する
- テストの一番最初に、特定の環境に繋ぎこむgRPC Clientを生成する
- テスト実行後に、gRPCのコネクションをCloseする
上記を丸っと自動生成するようにしました。
実装
CreateBookというgRPCをテストする場合の例です。
テストの例
package scenarios
import (
"context"
"testing"
"github.com/kmurata/book-sample/grpcclient"
"github.com/kmurata/book-sample/bookservice"
)
type createBookTest struct {
t *testing.T
cli *grpcclient.Client
connClose func()
}
func Test_CreateBook(t *testing.T) {
test := createBookTest{t: t}
test.setUp()
t.Run("CreateBookが成功する", func(t *testing.T) {
req := &bookservice.CreateBook{
Title: "読唇術のススメ"
}
// gRPCを叩く
err := test.cli.CreateBook(context.Background(), req)
if err != nil {
test.t.Fatalf("failed to create book: %s", err.Error())
}
})
test.tearDown()
}
func (c *createBookTest) setUp() {
var err error
c.cli, c.connClose, err = Initialize()
if err != nil {
c.t.Fatalf("failed to Initialize: %s", err.Error())
}
}
func (c *createBookTest) tearDown() {
c.connClose()
}
テストファイルごとに共通になる部分を雛形にする
例えば上記のCreateBook以外に、GetBookというgRPCのテストを書く際にも、同じようなコードになりそうな部分を雛形にします。
主に createBook
, CreateBook
, c
とある部分をテンプレートタグとして扱っていきます。
package scenarios
import (
"testing"
"github.com/kmurata/book-sample/grpcclient"
)
type _apiName_Test struct {
t *testing.T
cli *grpcclient.Client
action *actions.Action
connClose func()
}
func Test__ApiName_(t *testing.T) {
test := _apiName_Test{t: t}
test.setUp()
// -- Implement here --
t.Run("テストケース", func(t *testing.T) {
})
// ----
test.tearDown()
}
func (_recv_ *_apiName_Test) setUp() {
var err error
_recv_.cli, _recv_.connClose, err = Initialize()
if err != nil {
_recv_.t.Fatalf("failed to Initialize: %s", err.Error())
}
}
func (_recv_ *_apiName_Test) tearDown() {
_recv_.connClose()
}
テンプレートタグとして、下記の文字列を用意しました。
-
_apiName_
: gRPC名のローワーキャメルケースが入る部分(createBook) -
_ApiName_
: gRPC名のアッパーキャメルケースが入る部分(CreateBook) -
_recv_
: レシーバーの変数名が入る部分(c)
これらのテンプレートタグを、新しくgRPCのテストファイルを作った際に良い感じに置換してくれるようにします。
自動生成コマンド
package main
import (
"bufio"
"fmt"
"io/ioutil"
"log"
"os"
"strings"
"github.com/iancoleman/strcase"
)
const (
tplPath = "./cmd/gen_api_test/api_test.go.tpl"
outputDir = "./scenarios"
tplCamelTag = "_ApiName_"
tplLowerCamelTag = "_apiName_"
tplRecvTag = "_recv_"
)
// templateを読み込み、テストケースのファイルを生成する
func main() {
log.Println("=== APIテストファイル生成開始 ===")
tplContent, err := readTpl(tplPath)
if err != nil {
log.Fatal(err)
}
snakeApiName := scanSnakeApiName()
newContent := replaceTplContent(tplContent, snakeApiName)
outFilePath := fmt.Sprintf("%s/%s_test.go", outputDir, snakeApiName)
if err := writeFile(outFilePath, newContent); err != nil {
log.Fatal(err)
}
log.Printf("API名: %s\n", snakeApiName)
log.Printf("出力先: %s\n", outFilePath)
log.Println("=== APIテストファイル生成成功 ===")
}
func scanSnakeApiName() string {
fmt.Printf("テスト対象API名を指定してください。(例: create_book)\n>> ")
scanner := bufio.NewScanner(os.Stdin)
var answer string
for scanner.Scan() {
answer = scanner.Text()
if answer == "" {
fmt.Println("空文字は使用できません。再度入力してください。")
continue
}
break
}
return answer
}
func readTpl(filePath string) (string, error) {
bytes, err := ioutil.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("ファイル読み込みに失敗: %w", err)
}
return string(bytes), nil
}
func replaceTplContent(tplContent, snakeApiName string) string {
r1 := strings.ReplaceAll(tplContent, tplCamelTag, strcase.ToCamel(snakeApiName))
r2 := strings.ReplaceAll(r1, tplLowerCamelTag, strcase.ToLowerCamel(snakeApiName))
r3 := strings.ReplaceAll(r2, tplRecvTag, snakeApiName[0:1])
return r3
}
func writeFile(outFilePath, content string) error {
if err := ioutil.WriteFile(outFilePath, []byte(content), 0777); err != nil {
return fmt.Errorf("ファイル書き込みに失敗: %w", err)
}
return nil
}
やっていることはシンプルで、
- テスト対象のAPI名の入力を促す
- 入力されたAPI名から、テストファイルをテンプレートをもとに自動生成する
といった感じです。
実行
$ go run cmd/gen-api-test/main.go
2021/08/30 15:25:08 === APIテストファイル生成開始 ===
テスト対象API名を指定してください。(例: create_book)
>> create_book
2021/08/30 15:25:16 API名: create_book
2021/08/30 15:25:16 出力先: ./scenarios/create_book_test.go
2021/08/30 15:25:16 === APIテストファイル生成成功 ===
すると、下記のようなテストケースが作成されました!
package scenarios
import (
"context"
"testing"
"github.com/kmurata/book-sample/grpcclient"
"github.com/kmurata/book-sample/bookservice"
)
type createBookTest struct {
t *testing.T
cli *grpcclient.Client
connClose func()
}
func Test_CreateBook(t *testing.T) {
test := createBookTest{t: t}
test.setUp()
// -- Implement here --
t.Run("テストケース", func(t *testing.T) {
})
// ----
test.tearDown()
}
func (c *createBookTest) setUp() {
var err error
c.cli, c.connClose, err = Initialize()
if err != nil {
c.t.Fatalf("failed to Initialize: %s", err.Error())
}
}
func (c *createBookTest) tearDown() {
c.connClose()
}
あとはテストケースの中身だけ書いていけばOKですね。
最後に
今回はgRPC単位の結合テストにおいて、テストファイルを共通処理含めて自動生成したお話を書きました。
様々なシーンにおいて似たような活かし方ができるかと思うので、ぜひ参考にしてみてください。