現在時刻のような実行の度に値が変わるような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(最初のコードだけ)