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