SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

Go製のREST APIにUnitテストを追加した話

こんにちは!スマートキャンプ、エンジニアの中田です。

以前書いた記事の内容に引き続き今回も、現在業務で利用している Go のお話しです!

以前の記事

tech.smartcamp.co.jp

突然ですが、みなさんはテストを書かれてますか?

僕も「書いてます!」と声を張りたいところですが、4 月に新卒入社をしてから開発を始めた Go 製の API には何を隠そうテストがございません...。

開発初期は API へリクエストを手動で送りテストするような運用で特に事なかったのですが、開発が進むにつれコード差分による影響範囲が網羅できなくなったり、またそれにより大きな変更がしづらくなったり、とテストがないことによる悪影響が徐々に出現してきました。

そこで、テストを書こう。と思い立ってはみたものの、Go で API のテストってどう書くんだろう?と困ったのでその辺りを調査しながらサンプルアプリを実装してみました。

本記事ではそのサンプルアプリのお話、Go の API テストの中でも特に Unit テストを実装する方法についてご紹介いたします!

本サンプルアプリについて

今回テスト用に作成したサンプルアプリのコードは以下から参照できます。 (https://github.com/kiki-ki/go-test-example)

使用ライブラリ

使用しているライブラリは以下になります。

各ライブラリの用途を説明します。

chigorp は、業務で製作中の API の構成に似せるために導入しました。実際、制作中の API にはそれぞれgingormと違うライブラリを使用していますが、筆者の興味で今回は別ライブラリを用いています。

go-sqlmockはテスト用に mock DB を作成する意図で導入しています。詳しくは後述の Unit テスト項にて説明いたします。

Unit テスト

それでは早速、テストを書いていきます。 今回は、modelrepositoryhandlerの 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 さんの以下の記事に詳しく、参考にさせていただきました。

devblog.thebase.in

上記のテストコードでは、Unit テストと言いつつも mock DB の生成/設定処理を行っており統合テストのように見えます。より Handler のみにフォーカスしたテストを書く方法があるのか、次いで調査していければと思います。

まとめ

いかがでしたでしょうか? 本記事では Go の API の Unit テストの書き方についてご紹介いたしました。

今回は mock DB を使用しましたが、このパターンではあくまでクエリが想定の通りかのみのテストとなるため、そのクエリで想定した動作が行われるかの保証はできないというのが難点だなと感じました。 テスト用に実 DB を作成するパターンであればこの問題も解決できるので、またそちらも試してみようと思います。

また、本記事で扱っているテストコードは筆者がテストの書き方を学びながら実装したものなので、ご指摘/ご意見は大歓迎です!

最後までお読みくださりありがとうございました!