現在時刻のような実行の度に値が変わるようなresponseを含んだAPIのテストについて

github.com

昨日に引き続きgo-webtestの話。

現在時刻のような実行の度に値が変わるようなresponseを含んだAPIのテストがしたいとする。こういう場合はそもそも固定値を返せるように依存するコンポーネントを注入できるような構成にしておくのが良い。

依存の注入方法について

注入方法は大まかに以下の2つ

  • Handlerを特定のstructのメソッドにして、そのstructのfieldに依存をもたせる
  • Handlerを定義するのではなく、Handlerを生成する関数を定義する
  • (contextにWithValue()で依存を持たせる)

1つ目の例は世の中にあふれているので2つ目の例を書くことにする。詳しい話が知りたい場合には以下の記事を読んだ方が早いかもしれない。

(DIコンテナなどがどうこうみたいな話はやり方の話なので省略(例えばwebAPIを作成する時にコード生成(出力)を利用するか手書きするかみたいな話))

Handlerを定義するのではなく、Handlerを生成する関数を定義する

今回作成するAPIは以下のようなもの。

$ http -b GET :8080/now
{
    "now": "2019-09-18T18:16:53+09:00"
}

現在時刻を返すだけのAPI。現在時刻に限らずDBのauto-incrementだとかUUIDだとか入力値に寄らず現在のグローバルな状態を元に生成される値が存在することがある。それらの例のうちもっとも単純なもの例として見て欲しい。

Handler関数(http.HandlerFunc)を返す関数として各APIのエンドポイントを定義してあげると、依存はただ単にその関数の引数になる。以下の様な感じ。

api.go (Now)

func Now(now func() time.Time) http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
        fmt.Fprintf(w, `{"now": "%s"}`, now().Format(time.RFC3339))
    }
}

そしてこれらをまとめてhttp.Handlerを返してあげるような関数でラッピングしてあげれば完成。

api.go (Handler)

func Handler(
    now func() time.Time,
) http.Handler {
    var mux http.ServeMux
    mux.HandleFunc("/now", Now(now))
    return &mux
}

main関数もおまけに書いておく

func main() {
    h := Handler(time.Now)
    http.ListenAndServe(":8080", h)
}

こうしてあげると以下のようにテストが書ける。ほとんど通常のweb APIのテストと変わらない感じ。

package main

import (
    "testing"
    "time"

    rfc3339 "github.com/podhmo/go-rfc3339"
    webtest "github.com/podhmo/go-webtest"
    "github.com/podhmo/go-webtest/try"
)

func TestAPI(t *testing.T) {
    h := Handler(func() time.Time {
        return rfc3339.MustParse("2000-01-01T00:00:10Z")
    })
    c := webtest.NewClientFromHandler(h)

    var want interface{}
    try.It{
        Code: 200,
        Want: &want,
    }.With(t, c,
        "GET", "/now",
    )
}

便利ですね(とはいえgo-webtestの紹介にはなっていない)。

以下の様なgolden dataが埋まっています。

{
  "modifiedAt": "2019-09-18T18:07:48.045620749+09:00",
  "data": {
    "request": {
      "method": "GET",
      "path": "/now"
    },
    "response": {
      "data": {
        "now": "2000-01-01T00:00:10Z"
      },
      "statusCode": 200
    }
  }
}

これを使ってopenAPI documentなどにexampleとして注入できないかなと妄想していたりもします。

既存のコードがゴミの場合

既存のコードがゴミの場合はこんなにうまくはいかない。さてどうしてやろうかと言うのが悩みどころ。そして大抵の場合domainやentity付近で作られているのでだるい(だるい)。。ゴミと呼ぶのは良くないですね。time.Nowなどが直接使われているテストのことが全く考えられていないコードのことです。ごめんなさい。

どうやるのが良いでしょうか?一番単純な方法はパッケージグローバルにnowのような関数を持ちそれを使うという方法かもしれません。個人的には依存が引数の関係性の外に現れるし、気にするべき状態が暗黙に存在することになるので好きではないやり方だったりします。

例えば以下のような感じでしょうか?

SetNow()などを作るのはオススメしないです。せめてWithNow()というようなteardown的な関数を返すような実装にしておきましょう(どうせSetNow()などが必要になるような設計しかできない人はテストでもどこかでcleanupを忘れる(ちなみにこういう埋め込みのNowに拒絶反応を持つ人はもっと早くにどこかでcleanupを忘れて不快感をつのらせます。うかつなので))。

now.go

package main

import "time"

var now func() time.Time

func Now() time.Time {
    if now != nil {
        return now()
    }
    return time.Now()
}

func WithNow(fn func() time.Time) func() {
    if now != nil {
        // prevとか保持する必要無いと思う。どうせ壊れている
        panic("heh")
    }
    now = fn
    return func() {
        now = nil
    }
}

テストは以下の様な形。io.Closerみたいなメソッドにteardownを持つような定義はテストで必ず実行させたい場合にはオススメしないです。teardownのような関数で返ってきた方がまだマシ。

package main

import (
    "testing"
    "time"

    rfc3339 "github.com/podhmo/go-rfc3339"
    webtest "github.com/podhmo/go-webtest"
    "github.com/podhmo/go-webtest/try"
)

func TestAPI(t *testing.T) {
    teardown := WithNow(func() time.Time {
        return rfc3339.MustParse("2000-01-01T00:00:10Z")
    })
    defer teardown()

    h := Handler()
    c := webtest.NewClientFromHandler(h)

    var want interface{}
    try.It{
        Code: 200,
        Want: &want,
    }.With(t, c,
        "GET", "/now",
    )
}

こういう依存って1つならまだどうにかなるんですけれど、複数になってくると見た目が直列なのでどの依存が何に対応しているのか追うのが辛くなってくるんですよね。あんまり良い方針だとは思えない。

(こういう定義がコピペでいろんなパッケージに現れてくると最悪ですね。さすがにそこまでひどいものはあまり経験したことが無いですが。「あなたの作っているのはツールですか?アプリですか?ツールならまだ許してあげないこともないですけど」みたいな問答をしたくなるレベル)。

responseだけを書き換えたい場合

responseだけを書き換えたい場合にも一応は対応しています。とはいえそれだけで済むことはあまり無いような気もしますが。

ModifyResponse() というフィールドを受け取れます。ここで返した値がそのテストで取得した値として扱われます。このときwantの中には以前のテストの結果が詰め込まれています。 (またWantを指定しなかった場合にはsnapshot testingの比較が行われません)

package main

import (
    "testing"

    webtest "github.com/podhmo/go-webtest"
    "github.com/podhmo/go-webtest/try"
    "github.com/podhmo/noerror"
)

func TestAPI(t *testing.T) {
    h := Handler()
    c := webtest.NewClientFromHandler(h)

    type response struct {
        Now string `json:"now"`
    }
    var got response
    var want interface{}

    try.It{
        Code: 200,
        Want: &want,
        ModifyResponse: func(res webtest.Response) interface{} {
            noerror.Must(t, res.ParseJSONData(&got))

            pp.Println("previous", want)
            pp.Println("current", got)

            got.Now = want.(map[string]interface{})["now"].(string)
            // nowを何か良い感じに扱うか無視する
            return got
        },
    }.With(t, c,
        "GET", "/now",
    )
}

実行結果。

$ go test
$ go test
"previous" map[string]interface {}{
  "now": "2019-09-18T18:51:37+09:00",
}
"current" main.response{
  Now: "2019-09-18T18:55:53+09:00",
}
PASS

無視したい場合

無視したい場合はomitする形でsnapshotをupdateしてくださいとできれば良いんですがまだサポートしていません。現状ではwantもgotも取り除く必要があります。注意してください。この辺りはもう少し良い方法を考えようかなとおもっています。

はじめからmapにしておくと便利かもしれません。

package main

import (
    "testing"

    webtest "github.com/podhmo/go-webtest"
    "github.com/podhmo/go-webtest/try"
    "github.com/podhmo/noerror"
)

func TestAPI(t *testing.T) {
    h := Handler()
    c := webtest.NewClientFromHandler(h)

    var got map[string]interface{}
    var want interface{}
    try.It{
        Code: 200,
        Want: &want,
        ModifyResponse: func(res webtest.Response) interface{} {
            noerror.Must(t, res.ParseJSONData(&got))
            delete(got, "now")
            delete(want.(map[string]interface{}), "now")
            return got
        },
    }.With(t, c,
        "GET", "/now",
    )
}

実はgo-webtest/snapshot/replaceというパッケージを作っていてこちらはsnapshotを読み込むタイミングでの置換をサポートしようと思っているのですがまだ統合が上手くいっていないです。

gist(最初のコードだけ)

go-webtestというパッケージを作ってました

github.com

go-webtestというパッケージを作ってました。

go-webtest?

大雑把に言うと以下のようなものの詰め合わせです

  • 手軽にresponseを取り出せるようなclient
  • web apiのテスト時に発生するフラストレーションを緩和するような機能
  • request/responseをtestdata以下にgoldenデータとして保存するsnapshot testing

そしてコンセプトは以下の通りです。

Sometimes, easy is better than simple

場合によっては便利な方がシンプルなことより嬉しいときがあったりしますね。そういう感じです。

APIのテストをしようとしたときにどういう体験をしたいかということを気にして作っていました。

手軽にsnapshot testingがしたい

例えば以下の様なAPIがあるとします(handlerの実装が知りたい人はこちら)。

$ http -b GET ":8080/api/add?x=10&y=20"
{
    "ans": 30
}

まず成功例だけを書いてみることにします。以下の様なテストコードになります。

test_main.go

package main

import (
    "net/http"
    "testing"

    webtest "github.com/podhmo/go-webtest"
    "github.com/podhmo/go-webtest/try"
)

func TestAPI(t *testing.T) {
    c := webtest.NewClientFromHandler(http.HandlerFunc(Add))
    var want interface{}
    try.It{
        Code: http.StatusOK,
        Want: &want,
    }.With(t, c, "GET", "/api/add?x=10&y=20")
}

初回の実行結果を覚えて以降は以前の実行結果と比較します(初回は必ず成功します)

$ go test -v
=== RUN   TestAPI
--- PASS: TestAPI (0.00s)
    snapshot.go:51: save snapshot data: "testdata/TestAPI.golden"
    snapshot.go:56: load snapshot data: "testdata/TestAPI.golden"
PASS
ok      m/00addtest 0.008s

例えばちょっと引数を変えて実行してみます。

diff --git a/handler_test.go b/handler_test.go
index 8540d62..719b86b 100644
--- a/handler_test.go
+++ b/handler_test.go
@@ -14,5 +14,5 @@ func TestAPI(t *testing.T) {
    try.It{
        Code: http.StatusOK,
        Want: &want,
-  }.With(t, c, "GET", "/api/add?x=10&y=20")
+   }.With(t, c, "GET", "/api/add?x=100&y=20")
 }

以下のように失敗します。

$ go test
--- FAIL: TestAPI (0.00s)
    snapshot.go:56: load snapshot data: "testdata/TestAPI.golden"
    handler_test.go:17: on equal check: jsondiff, got and want is not same.
        status=NoMatch
        {
            "ans": 30 => 120
        }
        
        left (want) :
            {"ans":30}
        right (got) :
            {"ans":120}
FAIL
exit status 1
FAIL    m/00addtest 0.004s

snapshotを更新しましょう。テスト関数の名前がTestAPIだったのでそれの部分文字列を渡してあげればマッチしたテスト名のsnapshotが更新されます(SNAPSHOT=1 のときには全てのsnapshotを更新します)。

$ SNAPSHOT=API go test
PASS
ok      m/00addtest 0.004s
$ go test
PASS
ok      m/00addtest 0.006s

今度は上手くいってます。このように記録されたものと比較して差分が出たらエラーにするようなものをsnapshot testingと言います。

似たようなものはpythonではcrvpysnapshottestなどが有ります。またsnapshot testingの雰囲気を掴みたい人にはjestのドキュメントなども良いかもしれません。

怠惰なassertionが殺意を招く

もうちょっとだけ続きます。今度はこちらの話です。

web apiのテスト時に発生するフラストレーションを緩和するような機能

これは単刀直入に言えばテストが失敗したときのエラーメッセージが豊かになっていて欲しいという話です。例えばAPIの変更の結果テストが壊れるとします。その壊れたテストを直すときにステータスコードだけ分かっても嬉しくなかったりします。

diff --git a/handler_test.go b/handler_test.go
index 8540d62..e6a1ed0 100644
--- a/handler_test.go
+++ b/handler_test.go
@@ -14,5 +14,5 @@ func TestAPI(t *testing.T) {
    try.It{
        Code: http.StatusOK,
        Want: &want,
-  }.With(t, c, "GET", "/api/add?x=10&y=20")
+   }.With(t, c, "GET", "/api/add?x=10&y=20&z=foo")
 }

例えば以下の様に。

$ go test
--- FAIL: TestAPI (0.00s)
    handler_test.go:17: unexpected error, status code, got is "400 Bad Request", but want is "200 OK"

分かることがステータスコードだけというのはけっこうツライですね。幾つかある不正な入力の可能性から誤りであったrequestのパラメーターなどを特定しなければいけない。特に途中参加したプロジェクトなどでテストの実行に時間がかかっている上でそのような状況だとストレスがやばい(やばい)。フラストレーションが溜まります。

せめて発生したエラーなども出てきて欲しいですよね。あるいは利用したvalidationライブラリのvalidation errorだとか。

まともなフレームワークを使っていたりまともな環境でコードを書いていれば何らかのエラー原因がresponse bodyに含まれるはずです(ですよね?)。なのでそれをテストコードで潰してしまうのは。。。良くないことですね。。。良くないことなんです。

そんなわけでしっかりとresponse bodyも見えていて欲しい。

$ go test
--- FAIL: TestAPI (0.00s)
    snapshot.go:56: load snapshot data: "testdata/TestAPI.golden"
    handler_test.go:17: unexpected error, status code, got is "400 Bad Request", but want is "200 OK"
        Response: ------------------------------
        HTTP/1.1 400 Bad Request
        Connection: close
        
        {"error": "strconv.Atoi: parsing \"foo\": invalid syntax"}
        ----------------------------------------
        
FAIL
exit status 1
FAIL    m/00addtest 0.006s

万策尽きてのtrace

意図しないresponseが返ってきてしまうテストの原因は何なんでしょうね?あるいは変にラップされた便利ライブラリ(のようにみえる)コードに依存した処理のテストコードなどではどのようなrequestが投げられているのか把握しづらかったりします。

ストレスに精神がやられて頭を働かせたくない状況だとかあったりしますね。万策尽きた状態のときだとか。とりあえず DEBUG=1 と付けるとrequest/responseをtraceしてくれます。

$ DEBUG=1 go test
2019/09/18 00:03:40 builtin debug trace is activated
=== RUN   TestAPI
    Request : ------------------------------
    GET /api/add?x=10&y=20&z=foo HTTP/1.1
    Host: example.com
    
    ----------------------------------------
    Response: ------------------------------
    HTTP/1.1 400 Bad Request
    Connection: close
    
    {"error": "strconv.Atoi: parsing \"foo\": invalid syntax"}
    ----------------------------------------
--- FAIL: TestAPI (0.00s)
    snapshot.go:56: load snapshot data: "testdata/TestAPI.golden"
    handler_test.go:17: unexpected error, status code, got is "400 Bad Request", but want is "200 OK"
        Response: ------------------------------
        HTTP/1.1 400 Bad Request
        Connection: close
        
        {"error": "strconv.Atoi: parsing \"foo\": invalid syntax"}
        ----------------------------------------
        
FAIL
exit status 1
FAIL    m/00addtest 0.003s

あー、なるほど。query stringに数値だけではなく文字列が混ざっていますね。なるほどなるほど。ちなみにテストが成功しているときにも使えます(testing.TBを使わずstderrに出力している辺りはちょっと直したいかもなーと思ったりもしています)。

$ DEBUG=1 go test -v
2019/09/18 00:22:20 builtin debug trace is activated
=== RUN   TestAPI
    Request : ------------------------------
    GET /api/add?x=100&y=20 HTTP/1.1
    Host: example.com
    
    ----------------------------------------
    Response: ------------------------------
    HTTP/1.1 200 OK
    Connection: close
    Content-Type: text/plain; charset=utf-8
    
    {"ans": 120}
    -------------------------------------------
 PASS: TestAPI (0.00s)
    snapshot.go:56: load snapshot data: "testdata/TestAPI.golden"
PASS
ok      m/00addtest 0.004s

あとこのtraceはrouter側の"xxx handler is reached (matched)"などのdebug表示と組み合わさっていると404のときの原因を調べるのにも意外と便利な気がしています。

他にももう少し色々あるんですが今回はこのへんでおしまい。

gist