こんにちは!スマートキャンプ、エンジニアの中田です。
以前書いた記事の内容に引き続き今回も、現在業務で利用している Go のお話しです!
以前の記事
突然ですが、みなさんはテストを書かれてますか?
僕も「書いてます!」と声を張りたいところですが、4 月に新卒入社をしてから開発を始めた Go 製の API には何を隠そうテストがございません...。
開発初期は API へリクエストを手動で送りテストするような運用で特に事なかったのですが、開発が進むにつれコード差分による影響範囲が網羅できなくなったり、またそれにより大きな変更がしづらくなったり、とテストがないことによる悪影響が徐々に出現してきました。
そこで、テストを書こう。と思い立ってはみたものの、Go で API のテストってどう書くんだろう?と困ったのでその辺りを調査しながらサンプルアプリを実装してみました。
本記事ではそのサンプルアプリのお話、Go の API テストの中でも特に Unit テストを実装する方法についてご紹介いたします!
本サンプルアプリについて
今回テスト用に作成したサンプルアプリのコードは以下から参照できます。 (https://github.com/kiki-ki/go-test-example)
使用ライブラリ
使用しているライブラリは以下になります。
- HTTP ルータ:chi
- ORM:gorp
- SQL ドライバの mock:go-sqlmock
各ライブラリの用途を説明します。
chi
とgorp
は、業務で製作中の API の構成に似せるために導入しました。実際、制作中の API にはそれぞれgin、gormと違うライブラリを使用していますが、筆者の興味で今回は別ライブラリを用いています。
go-sqlmock
はテスト用に mock DB を作成する意図で導入しています。詳しくは後述の Unit テスト項にて説明いたします。
Unit テスト
それでは早速、テストを書いていきます。
今回は、model
、repository
、handler
の 3 パッケージに対してそれぞれに Unit テストを作成していきます。
Model
まずはmodel
のテストです。
プロダクトコード
type User struct { Id int `db:"id" json:"id"` Name string `db:"name" json:"name"` Email string `db:"email" json:"email"` Age int `db:"age" json:"age"` } func (u *User) IsOverTwentyYearsOld() bool { return u.Age >= 20 }
テストコード
model
パッケージに依存先がある場合は少ないと思うので、通常の関数などのテストと同様、特に考えることなく以下のように書けました。
func TestUser_IsOverTwentyYearsOld(t *testing.T) { cases := map[string]struct { in model.User want bool }{ "eqaul": {in: model.User{Age:20}, want: true}, "above": {in: model.User{Age:21}, want: true}, "below": {in: model.User{Age:19}, want: false}, } for k, tt := range cases { t.Run(k, func(t *testing.T) { got := tt.in.IsOverTwentyYearsOld() if tt.want != got { t.Errorf("want: age(%d) = %v, got: %v", tt.in.Age, tt.want, got) } }) } }
Repository
次にrepository
のテストです。
プロダクトコード
type executer = gorp.SqlExecutor type UserRepository interface { Find(uId int, e executer) (model.User, error) ...(snip) } type userRepository struct {} func (r *userRepository) Find(uId int, e executer) (model.User, error) { var u model.User err := e.SelectOne(&u, "SELECT * FROM users WHERE id = ?", uId) if err != nil { return model.User{}, err } return u, nil } ...(snip)
テストコード
上記の*userRepository.Find(-)
メソッドの動作が確認できるようなテストを書いていきます。この際に困るのが、テスト用の DB をどうするのかという話です。この解として、大きく以下の 2 パターンがあるかと思います。
- 実際にテスト用の DB を立てる
- mock DB を使用する
今回は導入の容易な「mock DB を使用する」パターンでテストを書いてみます。
まず、mock のライブラリとしてgo-sqlmockを導入します。
今回は ORM を利用しているので、DB のコンストラクタにsqlmock
で作成した*sql.DB
を渡してテスト用の DB のインスタンスを生成するようにしました。
この生成処理は DB の絡むテストでは頻用されるので、testutil
パッケージを切り関数を作成しておきます。
- db
type DB interface { Conn() *gorp.DbMap Close() error } func NewDB(sqlDB *sql.DB) DB { dbmap := &gorp.DbMap{Db: sqlDB, Dialect: gorp.MySQLDialect{}} dbmap.TraceOn("[gorp]", &logger{}) addTableSettings(dbmap) return &db{ connection: dbmap, } } type db struct { connection *gorp.DbMap } func (db *db) Conn() *gorp.DbMap { return db.connection } func (db *db) Close() error { err := db.connection.Db.Close() if err != nil { return err } return nil } ...(snip)
- testutil
func NewMockDB(t *testing.T) (database.DB, sqlmock.Sqlmock) { t.Helper() sqlDB, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } db := database.NewDB(sqlDB) return db, mock }
上記より、mock として使用できる db が生成できるようになったので、テストを作成します。
- Test
func TestUserRepository_Find(t *testing.T) { db, mock := testutil.NewMockDB(t) defer db.Close() want := model.User{ Id: 1, Name: "taro", Email: "taro@chan.com", Age: 5, } rows := sqlmock.NewRows([]string{"id", "name", "email", "age"}). AddRow(want.Id, want.Name, want.Email, want.Age) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM users WHERE id = ?`)). WithArgs(want.Id). WillReturnRows(rows) got, err := repository.NewUserRepository().Find(want.Id, db.Conn()) if err != nil { t.Error(err) } if err := mock.ExpectationsWereMet(); err != nil { t.Error(err) } if want != got { t.Errorf("want: %v, got: %v", want, got) } }
このように書けました。
go-sqlmock
周りのコードの解説をすると、
ExpectQuery(-)
メソッドで期待するクエリを指定し、WillReturnRows(-)
メソッドで取得結果を指定しています。最後にExpectationsWereMet()
メソッドで mock に設定された期待値通りのクエリが実行されたかの検証をしています。
その他の流れは通常と変わりなく、関数の戻り値が期待値とイコールかの検証を行ってテストを終えています。
Handler
最後にhandler
のテストです。
プロダクトコード
type UserHandler interface { Show(http.ResponseWriter, *http.Request) ...(snip) } type userHandler struct { db database.DB userRepo repository.UserRepository } func (h *userHandler) Show(w http.ResponseWriter, r *http.Request) { uId, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { w.WriteHeader(http.StatusBadRequest) render.JSON(w, r, err.Error()) return } u, err := h.userRepo.Find(uId, h.db.Conn()) if err != nil { w.WriteHeader(http.StatusInternalServerError) render.JSON(w, r, err.Error()) return } w.WriteHeader(http.StatusOK) render.JSON(w, r, u) } ...(snip)
テストコード
func TestUserHandler_Show(t *testing.T) { w := httptest.NewRecorder() r := testutil.NewRequestWithURLParams( t, "GET", "/dummy", nil, testutil.URLParam{Key: "userId", Val: "1"}, ) want := testutil.AssertResponseWant{ StatusCode: 200, Body: "a", } db, mock := testutil.NewMockDB(t) defer db.Close() u := model.User{ Id: 1, Name: "taro", Email: "taro@chan.com", Age: 5, } rows := sqlmock.NewRows([]string{"id", "name", "email", "age"}). AddRow(u.Id, u.Name, u.Email, u.Age) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM users WHERE id = ?`)). WithArgs(u.Id). WillReturnRows(rows) h := handler.NewUserHandler(db) h.Show(w, r) res := w.Result() defer res.Body.Close() testutil.AssertResponse(t, res, want, "./testdata/user/show_res.golden") }
net/http/httptest
パッケージのNewRecorder()
、NewRequest(-)
でリクエストとレスポンスライターを作成し、ハンドラーを通った後にレスポンスを検証することでテストしています。また、前項同様にここでもrepository
を利用するので mock DB を呼んでいます。
testutil
でラップしている関数の詳細は以下になります。
NewRequestWithURLParams(-)
type URLParam struct { Key, Val string } func NewRequestWithURLParams(t *testing.T, method string, target string, body io.Reader, params ...URLParam) *http.Request { t.Helper() r := httptest.NewRequest(method, target, body) return addURLParams(t, r, params...) } func addURLParams(t *testing.T, r *http.Request, params ...URLParam) *http.Request { t.Helper() ctx := chi.NewRouteContext() for _, p := range params { ctx.URLParams.Add(p.Key, p.Val) } newR := r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx)) return newR }
今回は HTTP ルータにchi
を使用しており、URLParameter の読み込みを Handler 内で行っているため、この関数で URLParameter を埋め込んだ*http.Request
を生成できるようにしています。
AssertResponse(-)
func AssertResponse(t *testing.T, got *http.Response, want AssertResponseWant, path string) { t.Helper() if want.StatusCode != got.StatusCode { t.Errorf("statusCode: want=%d, got=%d", want.StatusCode, got.StatusCode) } assertResponseBodyWithFile(t, got, path) }
この関数では、HTTP ステータスコードとボディの検証を行っています。 この辺りのラップの仕方は BASE さんの以下の記事に詳しく、参考にさせていただきました。
上記のテストコードでは、Unit テストと言いつつも mock DB の生成/設定処理を行っており統合テストのように見えます。より Handler のみにフォーカスしたテストを書く方法があるのか、次いで調査していければと思います。
まとめ
いかがでしたでしょうか? 本記事では Go の API の Unit テストの書き方についてご紹介いたしました。
今回は mock DB を使用しましたが、このパターンではあくまでクエリが想定の通りかのみのテストとなるため、そのクエリで想定した動作が行われるかの保証はできないというのが難点だなと感じました。 テスト用に実 DB を作成するパターンであればこの問題も解決できるので、またそちらも試してみようと思います。
また、本記事で扱っているテストコードは筆者がテストの書き方を学びながら実装したものなので、ご指摘/ご意見は大歓迎です!
最後までお読みくださりありがとうございました!