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を使うと部分的にテストする際に便利です。
カスタムマッチャ
レスポンスを検証する対象としてBodyやCokkie、Header等がありますが、独自のマッチャを作って検証することもできます。
インタセプター
HTTPリクエストを送る前にインタセプターを挟んで、http.Request
を元に送る内容を加工できます。
参考記事
上記のAPIのサンプルは各エントリポイントでGorm
を使ったDBアクセスをしています。以下の記事では go-sqlmock
を使ったGorm
アプリケーションのテスト方法も書いてますので、もしご興味があれば参考にしてみてください!
simple-minds-think-alike.hatenablog.com