はじめに
よくGOに触れるものです。
以前、GCPのクライアントライブラリを使ったコードを書いていたときに、それをテスタブルに書くにはどうすれば良いのか試行錯誤したので、その内容について書いていこうと思います。
特にGCPに偏った話ではなく、外部ライブラリを用いているコードに対しても応用が効くので、誰かの助けになればと思います。
最終的に出来上がったコードはGitHubで公開しています。
初期コード
今回はBigQueryのクライアントライブラリを例にとって説明します。
ローカルからBigQueryに接続する方法は省略していますが、権限をもったサービスアカウントのキーをローカル環境変数のGOOGLE_APPLICATION_CREDENTIALSに読ませています。
まずは適当なGOプロジェクトを作成して、BigQueryのクライアントライブラリをinstallします。
$ mkdir go-sample $ cd go-sample $ go mod init go-sample $ go get cloud.google.com/go/bigquery $ touch main.go $ mkdir bigquery $ mkdir pkg $ cd bigquery $ touch export.go $ cd ../pkg $ touch client.go
main.go、bigquery/ecport.go、pkg/client.goは以下のようになります
main.go
package main import ( "context" "fmt" "go-sample/bigquery" "go-sample/pkg" ) func main () { ctx := context.TODO() projectID := "xxx" client, err := pkg.Client(ctx, projectID) if err != nil { fmt.Println(err) } if err := bigquery.Export(ctx, client); err != nil { fmt.Println(err) } }
bigquery/export.go
package bigquery import ( "cloud.google.com/go/bigquery" "context" "fmt" ) type Item struct { Name string Gender string } func Export(ctx context.Context, client *bigquery.Client) error { dataset := "test" table := "test-table" item := []Item{ { Name: "kendric", Gender: "male", }, } if err := client.Dataset(dataset).Table(table).Inserter().Put(ctx, item); err != nil { return fmt.Errorf("Failed to insert record to Bigquery.") } fmt.Println("Finished to insert record to bigquery.") return nil }
pkg/client.go
package pkg import ( "cloud.google.com/go/bigquery" "context" "fmt" ) func Client(ctx context.Context, projectID string) (*bigquery.Client, error){ client, err := bigquery.NewClient(ctx, projectID) if err != nil { return nil, fmt.Errorf("Failed to get client.") } return client, nil }
Export()の中ではClientを取得して、BigQueryデータセット、BigQueryテーブルを指定してClientから呼び出されるメソッドに代入して、itemをinsertしています。
とっても簡易的なコードですね。
さてテスタブルに書き換えていきましょう。
Test
Test内容
まずこのExport()のテストとしてどんな内容のものを書けばいいか考えてみましょう。
といっても、とても簡素なのであまりテストしがいが無いですが、汎用的に応用できる考え方で進めていきます。
BigQueryライブラリから呼び出されるメソッドは外部定義されており、そもそも開発元でテストコードがあることでしょう。
なので、責任領域を分けて、Export()内の他の処理のテストに注力することにします。
その際、BigQueryライブラリから呼び出されるメソッドはすべてmock化すればよく、そうすると他の処理のテストに集中できます。
mock化するメソッドをwrapする
mock化したいということはわかりました。
まず思いつくのは、Export()は第2引数でclientを受け取っていて、そのメソッドを呼びしています。
であれば、clientのmockを定義してTestの際にExportに渡せば良いことでしょう。
ですが、clientから呼ばれるメソッドは Dataset() → Table() → Inserter() → Put()
とチェーンされているためそのmockを定義することは容易ではありません。
そんなときはもっと単純に可読性高く変更しましょう。
まずはチェーンされているメソッドの箇所をwrapします。
bigquery/export.go
func Export(ctx context.Context, client *bigquery.Client) error { dataset := "test" table := "test-table" item := []Item{ { Name: "kendric", Gender: "male", }, } if err := putRecord(ctx, dataset, table, item, client); err != nil { return err } fmt.Println("Finished to insert record to bigquery.") return nil } func putRecord(ctx context.Context, dataset string, table string, item []Item, client *bigquery.Client) error { if err := client.Dataset(dataset).Table(table).Inserter().Put(ctx, item); err != nil { return fmt.Errorf("Failed to insert record to Bigquery.") } return nil }
こうすると、putRecordをmock化すればいいと視覚的にわかりやすくなります。
ただ、Export()の引数でclientを受け取ってそれをputRecordで用いているため、まだ汎用的なmockを作ることができません。
であれば思いつくのが、clientは事前に構造体に入れておいて、レシーバ引数で受け取れるようにすればよいのではないでしょうか?
それならばTestの際にclientに適当な仮の値を入れることもできます。
実際に一気に書き換えてみましょう。
テスタブルに修正
書き換えたコードが以下です。
main.go
package main import ( "context" "fmt" "go-sample/bigquery" "go-sample/pkg" ) func main () { ctx := context.TODO() projectID := "xxx" client, err := pkg.Client(ctx, projectID) if err != nil { fmt.Println(err) } bqClientImpl := &bigquery.BigqueryClientImpl{client} bq := bigquery.Exporter{bqClient} if err := bq.Export(ctx); err != nil { fmt.Println(err) } }
bigquery/export.go
package bigquery import ( "cloud.google.com/go/bigquery" "context" "fmt" ) type Item struct { Name string Gender string } type ( BigqueryClient interface { putRecord(ctx context.Context, dataset string, table string, item []Item) error } Exporter struct { Bq BigqueryClient } BigqueryClientImpl struct { BqClient *bigquery.Client } MockBigqueryClientImpl struct { BqClient *bigquery.Client } ) func (b *Exporter) Export(ctx context.Context) error { dataset := "test" table := "test-table" item := []Item{ { Name: "kendric", Gender: "male", }, } if err := b.Bq.putRecord(ctx, dataset, table, item); err != nil { return err } fmt.Println("Finished to insert record to bigquery.") return nil } func (b *BigqueryClientImpl) putRecord(ctx context.Context, dataset string, table string, item []Item) error { if err := b.BqClient.Dataset(dataset).Table(table).Inserter().Put(ctx, item); err != nil { return fmt.Errorf("Failed to insert record to Bigquery.") } return nil }
重要なのはExport()の呼び出し元で、正規かmockかを選択できるようにしたことです。
Exporter構造体はmockも受け付けることができるので、Testの際は柔軟に切り替えることができます。
またinterfaceも定義して、putRecordを置くことで、実際のputRecordの入ったstructをExporterが受け取ることができます。
Testコード
実際のテストコードもmain.goで構造体を初期化したのと同じやり方で書けます。
bigquery/export_test.go
package bigquery import ( "context" "testing" ) func Test_Export(t *testing.T) { mockBqClientImpl := &MockBigqueryClientImpl{nil} bq := Exporter{mockBqClientImpl} t.Run("Test to put record into bigquery", func(t *testing.T) { ctx := context.TODO() if err := bq.Export(ctx); err != nil { t.Fatal("expect error") } }) }
bigquery/export_mock.go
package bigquery import ( "context" ) func (m *MockBigqueryClientImpl) putRecord(ctx context.Context, dataset string, table string, item []Item) error { return nil }
代わりにmockを代入するだけです。
clientにはnilを代入しています。
mock化したputRecord()はお好きな用に書き換えましょう。
これでExport()内のBigQueryクライアントライブラリメソッドをすべてmock化できました。
例のコードはBigQueryのメソッド以外に処理を用意していないため、mock化後は意味のないコードになりましたが、BigQueryテーブルに書き込む際にデータ整形処理などが入っている場合は、その処理のテストに注力することができます。
まとめ
今回はGCPのクライアントライブラリを例にとりましたが、外部ライブラリ全般に応用できるので、mock化する際は参考にしてみてください。