これは、「BASE Advent Calendar 2018」4日目の記事です。
BASEでサーバーサイドエンジニアをやっている、東口(@hgsgtk)です。BASE BANKというBASEの子会社にて金融事業の立ち上げを行っています。
以前投稿した、Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポートという記事の中で、レイヤ構造を成長させていくためのユニットテストについて言及させていただいていました。こちらのエントリーにて進めていたコードベースでは、全体で約85%程度のテストカバレッジとなっています。本日は、そんなGoのユニットテストについての内容です。
ユニットテストの知見
現在、Goのユニットテストについての知見は数多く見られ、テーブル駆動テスト・サブテストなどといった基本的なところから、非公開(unexported)な機能を使ったテストやGoのAPIのテストにおける共通処理で紹介されているようなインテグレーションテストの知見など、充実した情報量となっていると思います。
ただ、資料を漁っていて「どのようなテストヘルパーを作っているか」・「テーブル駆動テストをどのように活用しているか」といった、現場のノウハウはまだ探しづらいなと感じています。
そのため、今回の記事では、筆者がAPI開発のために使っていたテストTipsや共通化のためのテストヘルパーについて紹介してみます。
具体的には、「http.Handlerのテスト」・「実行ごとに結果が異なる処理のテスト」・「外部ライブラリの活用」の3点についてそれぞれ紹介していきます。
http.Handlerのテスト
http.Handlerのユニットテストでは、httptestパッケージを活用して、ハンドラーに対してリクエストを送り、レスポンスを検証するといったテストを書きます。その際、期待値定義をどのようにするか分かれるところかと思いますが、筆者は対象ハンドラーに対するリクエスト・レスポンスの期待値をJSONで定義するようにしています。
実際に次のように、ユーザーのリソースの取得を行うようなHandlerコードをのテスト例を紹介します。
. ├── interfaces │ └── controller │ ├── testdata │ │ └── user_controller │ │ ├── request.json │ │ └── response.golden │ ├── user_controller.go │ └── user_controller_test.go └── testutil └── handler.go
筆者のプロジェクトでは、現在、テストヘルパー群をtestutil
というサブパッケージにまとめており、各テストケースからは、testutil
パッケージが提供する関数を使用する構造となっています。
実際に、ユーザーのリソース取得を行うHandlerを例に見てみます。
func TestUserController_Handler(t *testing.T) { // prepare http.Request and http.ResponseWriter w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/v1/users", nil) // execute the target method c := controller.UserController{} c.Handler(w, r) res := w.Result() defer res.Body.Close() testutil.AssertResponse(t, res, http.StatusOK, "./testdata/user_controller/response.golden") }
testutil.AssertResponse
というResponse Assertion用のテストヘルパーを提供しています。
// AssertResponse assert response header and body. func AssertResponse(t *testing.T, res *http.Response, code int, path string) { t.Helper() AssertResponseHeader(t, res, code) AssertResponseBodyWithFile(t, res, path) }
testutil
パッケージで定義したAssertResponse
は、レスポンスヘッダー・ボディを検証する機能を持っています。
下記がヘッダー検証関数AssertResponseHeader
の中身です。
// AssertResponseHeader assert response header. func AssertResponseHeader(t *testing.T, res *http.Response, code int) { t.Helper() // ステータスコードのチェック if code != res.StatusCode { t.Errorf("expected status code is '%d',\n but actual given code is '%d'", code, res.StatusCode) } // Content-Typeのチェック if expected := "application/json; charset=utf-8"; res.Header.Get("Content-Type") != expected { t.Errorf("unexpected response Content-Type,\n expected: %#v,\n but given #%v", expected, res.Header.Get("Content-Type")) } }
ステータスコード・Content-Typeをチェックしています。レスポンス検証の際、おまじないのようにこちらのアサーションは書くことになるので、共通化しておくと新規のhandler実装も楽になるかと思います。
次にBody検証関数AssertResponseBodyWithFile
です。
// AssertResponseBodyWithFile assert response body with test file. func AssertResponseBodyWithFile(t *testing.T, res *http.Response, path string) { t.Helper() rs := GetStringFromTestFile(t, path) body, err := ioutil.ReadAll(res.Body) if err != nil { t.Fatalf("unexpected error by ioutil.ReadAll() '%#v'", err) } var actual bytes.Buffer err = json.Indent(&actual, body, "", " ") if err != nil { t.Fatalf("unexpected error by json.Indent '%#v'", err) } assert.JSONEq(t, rs, actual.String()) }
筆者のプロジェクトでは、別定義したJSON文字列との比較によってレスポンスボディを検証しています。使い方の流れは下記になります。
testdata
以下に期待値となるJSONを.golden
拡張子で配置- テストケースにファイルパスを指定。(今回の場合、
"./testdata/user_controller/response.golden"
となります。) AssertResponseBodyWithFile
にて対象ファイルと実レスポンスを検証
.golden
拡張子の使用方法については、Testing with golden files in Goを参考にさせていただきました。テストのOutputの期待値をtestdata
内に別ファイルとして定義する際に、標準ライブラリ内でよく使用されている方法のようです。
{ "user": { "id": 1 } }
この際、JSON同士の比較の可視性のために、部分的にstretchr/testifyのassert.JSONEq
を利用しています。
そして、ファイルから文字列を取得する実装GetStringFromTestFile
では、ioutil.ReadFile
を利用することで実現しています。
// GetStringFromTestFile get string from test file. func GetStringFromTestFile(t *testing.T, path string) string { t.Helper() bt, err := ioutil.ReadFile(path) if err != nil { t.Fatalf("unexpected error while opening file '%#v'", err) } return string(bt) }
これまで紹介していたレスポンスボディを別ファイル定義する手法は、リクエストボディが必要なテストケースでも同様に使用しています。リクエストで必要なjsonファイルは、Outputの期待値として定義していないため、.json
拡張子としています。
func TestUserController_Handler(t *testing.T) { // prepare http.Request and http.ResponseWriter w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/v1/users", strings.NewReader(testutil.GetStringFromTestFile(t, "./testdata/user_controller/request.json")) // execute the target method c := controller.UserController{} c.Handler(w, r) res := w.Result() defer res.Body.Close() testutil.AssertResponse(t, res, http.StatusOK, "./testdata/user_controller/response.golden") }
実行ごとに結果が異なる処理のテスト
UUIDや時刻を扱うコードなど実行ごとに結果が異なる処理の場合は、実装自体を「テスト可能な実装」にする必要があります。
時刻を扱うテスト
本章冒頭でも述べたとおり、時刻を使う処理がある場合はテストごとに結果が変わってしまいます。そのため、筆者は、サブパッケージとしてclock
パッケージを作成し、テストケース内で実行時刻を固定できるようにしています。clock
パッケージの中身はこれだけです。
package clock import "time" // Now is current time. // テスト時に差し替え可能にするため、グローバル変数として定義している var Now = time.Now
テスト時に差し替え可能とするため、グローバル変数にtime.Nowを設定しています。テスト時には、こちらの変数に任意のtime.Time
を設定することで時間を固定します。
なお、この実装の方法については、外部環境への依存をテストするという資料を参考にさせていただきました。そして、時間を期待値に固定するテストヘルパーを提供しています。
package testutil // SetFakeTime set fake time to clock package. func SetFakeTime(t time.Time) { clock.Now = func() time.Time { return t } }
テストケース内では、testutil.SetFakeTime
を使用することでテストケース内で任意の時間に設定できるようにしています。
UUID
UUIDに関してもテスト実行ごとに値が変わってしまいます。筆者はUUID生成に、github.com/satori/go.uuidを利用していますが、本ケースでは、go.uuidから使用するメソッドのみを必要としたInterfaceを作成しています。
package uuidgen import ( "github.com/satori/go.uuid" ) // UUIDGenerator is interface to generate uuid. type UUIDGenerator interface { V4() string } // UUID has generating method. type UUID struct { } // V4 wrap satori.uuid.NewV4() func (*UUID) V4() string { return uuid.NewV4().String() }
そして、UUID生成する実装では、UUIDGenerator
interfaceを受け入れる型として定義することでモック差し替え可能にしています。
例えば、次のように引数にUUIDGenerator
interfaceを指定した場合は、
func GetSampleUUID(g uuidgen.UUIDGenerator) { return g.V4() }
次のようにモック実装を作成して差し替えます。
type mockUUID struct{} func (*mockUUID) V4() string { return "sample-uuid-string" } func TestGetSampleUUID(t *testing.T) { actual := GetSampleUUID(&mockUUID{}) if diff := cmp.Diff("sample-uuid-string", actual); diff != "" { t.Errorf("differs: (-want +got)\n%s", diff) } }
外部ライブラリの活用
Goのユニットテストを書いていくにあたり、標準のtestingパッケージでどれだけやるのか、テストフレームワークを利用するのかは序盤で検討するかと思います。筆者は、基本的に標準ライブラリを使用していますが、補助的に次のライブラリを利用しました。それぞれ使用感も含めて紹介いたします。
google/go-cmp
github.com/google/go-cmp はGoogle非公式の値比較のライブラリです。構造体などの大きめな値比較をする際に使っている方が多いかと思います。
実際の使い方は公式のexampleが参考になりますが、例えば、構造体のアサーションをする場合には、次のように書くことができます。
func TestFuncHoge(t *testing.T) { expected := Hoge{ Moji: "hogehoge", AnotherStruct: Huga{ Moji: "hugahuga", }, Num: 1, Flag: false, } res := FuncHoge() if diff := cmp.Diff(res, expected); diff != "" { t.Errorf("Hogefunc differs: (-got +want)\n%s", diff) } }
テスト結果がFAIL
の場合は次のような表示になります。
=== RUN TestFuncHoge --- FAIL: TestFuncHoge (0.00s) main.go:56: Hogefunc differs: (-got +want) {Hoge}.AnotherStruct.Moji: -: "hugahuga" +: "hugahuga_diff" FAIL
構造体をそのまま比較できるので便利です。特に使って不便になることはないので、迷っていらっしゃる方がいれば使っていくのが良いかと思います。
DATA-DOG/go-sqlmock.v1
gopkg.in/DATA-DOG/go-sqlmock.v1 は、DBを使うコードをテストする際にsql.Driverをモックするものです。
実際にSQLを実行してデータを取得するような技術的実装を行う関数のテストにおいて、こちらのライブラリを使っています。
go-sqlmockは、各人が愚直に使うと少々辛いところがあったので、こちらのライブラリを不便少なく使うためにヘルパーを作成しています。
実際には、SQL自体をGoファイル内で定義していくのは見通しが悪く感じたので、次のようにSQLファイルをtestdata
ディレクトリに配置してテストで利用しています。
下記より、user情報を保存するコードに対するテストコードを例に紹介します。
. ├── infrastructure │ └── datastore │ ├── testdata │ │ └── user │ │ └── save.sql │ ├── user.go │ └── user_test.go └── testutil ├── error.go └── sqlmock.go
まず、テストヘルパーを利用したテストコードは次のようになります。
package datastore_test func TestUserStore_Save(t *testing.T) { // create database mock db, mock := testutil.NewMockSQLDB(t) // Helper 1 defer db.Close() // set expectation of mock query := testutil.GetSQLFromFile(t, "./testdata/user/save.sql") // Helper 2 mock.ExpectPrepare(query). ExpectExec(). WithArgs(1, 1, testutil.GetTestTime(t), testutil.GetTestTime(t)). WillReturnResult(1). WillReturnError(nil) // execute the target method s := datastore.UserStore{} result, err := s.Save(db, 1, 1) // assertion testutil.AssertMockExpectation(t, mock) // Helper 3 testutil.AssertError(t, err, tt.expectedErr) if tt.expected != result { t.Errorf("expected: %#v,\n given: %#v", tt.expected, result) } }
上記のテストコードに対して、関連するもので3つHelperを提供しています。
package testutil // Helper 1 // NewMockSQLDB create a new instance sql.DB for test mocking func NewMockSQLDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) { t.Helper() db, mock, err := sqlmock.New() if err != nil { t.Fatalf("failed to create sqlmock '%#v'", err) } return db, mock }
1つ目が、sqlmockのinstanceを生成するヘルパーです。ライブラリ系では、どうやって生成するか毎回思い出すのも面倒なのと、テスト用インスタンスの生成のエラーハンドリングにてテストコードが冗長になりがちなため、テストヘルパーとして提供しました。
次に、期待値となるSQL文を準備するためのヘルパーです。
package testutil // GetSQLFromFile get sql string which is quoted meta to use in sql-mock. func GetSQLFromFile(t *testing.T, path string) string { t.Helper() mq := GetStringFromTestFile(t, path) return regexp.QuoteMeta(mq) }
こちらでは、別ファイルのパスを受け取り、string型の文字列を返すヘルパーを作成しています。
sqlmockは、テスト時のSQLを正規表現で期待値との比較するのですが、「正規表現での比較」という点をテストコード実装者に意識してほしくないので、このようなヘルパーを用意しています。
テストコード実装者は、次のようにtestdata
ディレクトリ以下に期待値となるSQLをSQLファイルとして設置すればOKです。
INSERT INTO users (last_name,first_name,created,modified) VALUES (?,?,?,?)
最後に、sqlmockで設定した期待値と一致しているかを確認するテストヘルパーを提供しています。
// AssertMockExpectation assert mock expectation func AssertMockExpectation(t *testing.T, mock sqlmock.Sqlmock) { if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("there were unfulfilled expectations: %s", err) } }
これは、sqlmockのsqlmock.Sqlmock
の持つ関数をwrapしています。ライブラリ系の特殊なAssertionも、書き方を忘れがちなのと、設定するエラーメッセージは共通ですので、テストヘルパーとして仕様をWrapすると、各人でばらつきも少なくなるかと思います。
sqlmockの使用感ですが、昨今実データベースでテストするべきという言説が増えている通り、sqlmockで保護できるのが「期待したSQLが発行されているか」のみとなり、「そのSQLが正しいかどうか」についてはテスト保護対象外となる点が難点です。
筆者は、「SQLの妥当性」をチェックしやすいように、SQLのみ別ファイル定義する方法で、ミスを見逃すリスクの軽減に努めましたが、時間が許すのであればFixture機構を用意して実データベースでのテストを行うのがより安全かと思います。
実データベースを用いたテストについては、Golang API Testing the HARD wayという資料にて紹介していただいています。
golang/mock
github.com/golang/mock は、テスト時のモックを生成する選択肢として使われることが多いかと思います。 基本的な使い方として、下記のようなコマンドを実行することでモックを自動生成してくれます。
mockgen -source hoge.go -destination mock_hoge.go
自動生成したモックは、本ライブラリが提供するモックコントローラによって期待値を設定することができます。
ctrl := gomock.NewController(t) mockHoge := mock_hoge.NewMockHoge(ctrl) mockHoge.EXPECT(). Fuga("hoge", "huga"). Times(1). Return("hogehuga", nil)
筆者は、テーブル駆動テストでgomockを利用しているのですが、それぞれのテストケースで設定したい期待値が異なるため、期待値設定用の構造体を定義して動的に期待値を変えています。
type mockExpectedHogeFuga struct { callTime int arg string result string err error } func TestHoge_Fuga(t *testing.T) { tests := []struct { name string mockExpected mockExpectedHogeFuga expected bool }{ { name: "success_to_print_hogehuga", mockExpected: mockExpectedHogeFuga{ callTime: 1, arg: "hoge", result: "hogefuga", err: nil, }, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) mockHoge := mock_service.NewMockHoge(ctrl) mockHoge.EXPECT(). Fuga(tt.mockExpected.arg). Times(tt.mockExpected.callTime). Return(tt.mockExpected.result, tt.mockExpected.err) // テスト対象関数へモック注入し実行... }) } }
テストケースごとに期待値を変えることによって、テーブル駆動テストでも使いやすいようにしています。この手法を使うことでモックをテストケース内で柔軟に使うことができるため重宝しています。
gomock自体の使用感ですが、モック量が多くなってきたタイミングでは、gomockに任せてしまえばよいので、テスト効率は上がりました。ただし、Interfaceに対するモック実装に慣れていない状態では使わないほうが良いかと筆者は思います。モック実装を自前で作れる状態になった上で効率化のためのモック自動生成手段として導入するプロセスが、Goらしいコードを学ぶ上で有益だったと実感しています。
まとめ
今回は、Goのユニットテストの実践しているTipsについて書きました。テストヘルパーは、ライブラリ使用状況・コード構成によって、提供の仕方はそれぞれ微妙に異なるかもしれませんが、一つアイデアとして参考になれば幸いです。
明日は、id:tenkoma です!お楽しみに!