Simple minds think alike

より多くの可能性を

【Golang】apitest でEchoを使ったREST APIのテストを書く

apitestとは

Go言語のAPIテスティング用のライブラリの1つです。 github.com

apitestは

という特徴があり、個人的に好きでAPIのテスト書くのに使っています。

難点としては

  • websocket のテストは書けない(2021年3月時点)
    • その場合 httpexpect 等他のライブラリが候補にあがりそう。
  • サードパーティのライブラリのため都度バージョンアップが必要
    • 標準パッケージだけで賄いたい場合はnet/http/httptest使うのが良さそう

というところかと思います。

apitest の紹介のためにサンプルコードを書いてみました。どのようにテストを書けるのか紹介していきたいと思います。

サンプルコード

作ったサンプルはEchoで作った以下のREST APIを持つアプリケーションです

  • 書籍一覧 (GetIndex - パス: /books、メソッド: GET)
  • 書籍詳細 (GetDetail - パス: /books/1、メソッド: GET)
  • 書籍登録 (Post - パス: /books/、メソッド: POST)
  • 書籍登録 (Put - パス: /books/1、メソッド: PUT)

このEchoのサンプルは、apitest の以下のexamplesを元に作りました。

コードのリンク

コードの全ては紹介しきれないので、この記事中にはAPIの実装コード・テストコードの一部を載せてます。コード全体をみたい方はリンクを載せておきますので、よろしければご参照ください。

書籍一覧 (GetIndex)

一覧のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindAll()で書籍の一覧を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した一覧を返す

という実装です。

func (bh *BookHandler) GetIndex(c echo.Context) error {
    bks, err := bh.bookRepo.FindAll()

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    rl := &resultLists{Books: bks}
    return c.JSON(http.StatusOK, rl)
}

テストコード

bh.bookRepo.FindAll()で取得する書籍一覧をスタブにしています。 apitestを使ったテストコードでは、パス/booksにGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindAll() ([]model.Book, error) {
    bks := []model.Book{}
    t, _ := time.Parse("2006-01-02", "2021-01-01")

    bk1 := model.Book{Title: "Go言語の本", Author: "誰か"}
    bk1.ID = 1
    bk1.CreatedAt = t
    bk1.UpdatedAt = t
    bks = append(bks, bk1)

    b2 := model.Book{Title: "Go言語の本2", Author: "誰か2"}
    b2.ID = 2
    b2.CreatedAt = t
    b2.UpdatedAt = t
    bks = append(bks, b2)

    return bks, nil
}

func TestGetIndex(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books", h.GetIndex)

    apitest.New().
        Handler(e).
        Get("/books").
        Expect(t).
        Body(`
          {
              "Books": [
                  {
                      "ID": 1,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本",
                      "Author": "誰か"
                  },
                  {
                      "ID": 2,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本2",
                      "Author": "誰か2"
                  }
              ]
          }
      `).
        Status(http.StatusOK).
        End()
}

Handler()には、http.Handlerのインタフェース実装を渡します。

書籍詳細 (GetDetail)

詳細情報のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindByID()で書籍の詳細情報を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した詳細情報を返す

という実装です。

func (bh *BookHandler) GetDetail(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, err := bh.bookRepo.FindByID(id)

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}

テストコード

bh.bookRepo.FindByID()で取得する書籍詳細をスタブにしています。 apitestを使ったテストコードでは、パス/books/1にGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindByID(id uint64) (model.Book, error) {
    t, _ := time.Parse("2006-01-02", "2021-01-01")
    b := model.Book{Title: "Go言語の本", Author: "誰か"}
    b.ID = 1
    b.CreatedAt = t
    b.UpdatedAt = t
    return b, nil
}

func TestGetDetail(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books/:id", h.GetDetail)

    apitest.New().
        Handler(e).
        Get("/books/1").
        Expect(t).
        Body(`
          {
              "ID": 1,
              "CreatedAt": "2021-01-01T00:00:00Z",
              "UpdatedAt": "2021-01-01T00:00:00Z",
              "DeletedAt": null,
              "Title": "Go言語の本",
              "Author": "誰か"
          }
      `).
        Status(http.StatusOK).
        End()
}

書籍登録 (Post)

登録のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Create でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した登録した書籍情報を返す

という実装です。

func (bh *BookHandler) Post(c echo.Context) error {
    t := c.FormValue("title")
    a := c.FormValue("author")
    b := model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Create(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}

テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Create(b *model.Book) error {
    return nil
}

func TestPost(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.POST("/books", h.Post)

    // 正常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "新規書籍名").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

書籍更新 (Put)

更新のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Save でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した更新した書籍情報を返す

という実装です。

func (bh *BookHandler) Put(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, _ := bh.bookRepo.FindByID(id)

    t := c.FormValue("title")
    a := c.FormValue("author")
    b = model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Save(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}

テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Save(b *model.Book) error {
    return nil
}

func TestPut(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.PUT("/books/:id", h.Put)

    // 正常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

デバッグの仕方

Debugを使うとHTTPの内容がログ出力され分かりやすくなり、テストの修正に役立てます。

例えば、上記の TestPut のテストコードを以下のように変更し Fail するようにし、併せて Debug を追加します。

   // 正常系
    apitest.New().
        Debug(). ←追加
        Handler(e).
        Put("/books/"). ←ここを存在しないパスに変更
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

テストを実行すると、以下のように

  • Http requestのinbound内容
  • 最終的なHttp Responseの内容

がコンソールに出力され、テストが Fail した原因を見つけやすくなります。

----------> inbound http request
PUT /books/ HTTP/1.1
Host: sut
Content-Type: application/x-www-form-urlencoded

author=%E6%9B%B4%E6%96%B0%E5%BE%8C%E8%91%97%E8%80%85&title=%E6%9B%B4%E6%96%B0%E5%BE%8C%E6%9B%B8%E7%B1%8D%E5%90%8D

<---------- final response
Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler
HTTP/1.1 404 Not Found
Connection: close
Content-Type: application/json; charset=UTF-8

{"message":"Not Found"}

Duration: 173.685µs

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:153
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.092s
FAIL

Debugがないと以下のようなシンプルな出力内容で、200が返ることを期待しているが実際には404が返っていることが分かるくらいで情報が少ないです。

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:152
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.077s
FAIL

便利な機能

JSON path

APIから返ってきたJSON全体をテストするサンプルを書きましたが、JSON pathを使うと部分的にテストする際に便利です。

カスタムマッチャ

レスポンスを検証する対象としてBodyCokkieHeader等がありますが、独自のマッチャを作って検証することもできます。

インタセプター

HTTPリクエストを送る前にインタセプターを挟んで、http.Request を元に送る内容を加工できます。

参考記事

上記のAPIのサンプルは各エントリポイントでGormを使ったDBアクセスをしています。以下の記事では go-sqlmock を使ったGorm アプリケーションのテスト方法も書いてますので、もしご興味があれば参考にしてみてください!

simple-minds-think-alike.hatenablog.com

サンプルコード

github.com

参考資料