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

gitで更新のあったファイルに対してformatter(goimports)をかける

CIでlintのついでにformatter(gofmt, goimports, gofumpt)がかかっているかチェックしている環境があるとする。そこでformatされていないと怒られたファイルに対してformatterをかけたい。その方法のメモ。

あるコミットで変更されたファイルを集める

対象のコミットで変更されたファイルの一覧を集めてformatterにかけたい

git show --stat HEAD をparseしてみる?

例えば対象のコミットをHEADということにすると git show --stat HEAD の変更を利用するということが考えられる

以下の様な形。

$ git show --stat HEAD | grep -F '|'
 xxx/gen/mock/handler.go                                |   3 +-
 yyy/gen/mock/router.go                                 |   3 +-
 zzz/gen/mock/next_state.go                             |   5 +-
 aws/s3/gen/mock/s3.go                                  |   3 +-
 .../aws/aws-sdk-go/service/sqs/sqsifacemocks/SQSAPI.go | 122 +++++++++++------------

# 対象のファイルの一覧
$ git show | grep -F '|' | cut -d '|' -f 1
 xxx/gen/mock/handler.go                                
 yyy/gen/mock/router.go                                 
 zzz/gen/mock/next_state.go                             
 aws/s3/gen/mock/s3.go                                  
 .../aws/aws-sdk-go/service/sqs/sqsifacemocks/SQSAPI.go 

あとは一覧が取れればすんなり渡して行けば良いということに一見なりそうなのだけれど。そうはならない。ものによってはファイル名の出力が省略されてしまうことがある。

 .../aws/aws-sdk-go/service/sqs/sqsifacemocks/SQSAPI.go 

これが完全なファイルパスではないのでちょっと不便。

暫定的で頑健な普段の手癖

普段はあまり考えずにとりあえずbasenameだけあっていれば対象にしちゃって良いでしょ、と以下の様なコードを手癖で書いていたけれどもう少しまともなワンライナーを検討しても良いかもしれない。

for i in $(git show --stat HEAD | grep '|' | cut -d '|' -f 1); do find . -name "$(basename $i)"; done | xargs goimports -w

同一のbasenameのものが含まれるのでformatterに渡されるファイルが増える可能性はあるのだけれど、実用上はあまり問題にならない。まぁでももう少しキレイな方法を見出したい。

--stat=<column>

manを覗いてみると以下のようなことが書かれている。デフォルトのターミナルのcolum sizeは80でその値を元に良い感じに表示を調節しているよう。

$ man git-show
...

       --stat[=<width>[,<name-width>[,<count>]]]
           Generate a diffstat. By default, as much space as necessary will be used
           for the filename part, and the rest for the graph part. Maximum width
           defaults to terminal width, or 80 columns if not connected to a
           terminal, and can be overridden by <width>. The width of the filename
           part can be limited by giving another width <name-width> after a comma.
           The width of the graph part can be limited by using
           --stat-graph-width=<width> (affects all commands generating a stat
           graph) or by setting diff.statGraphWidth=<width> (does not affect git
           format-patch). By giving a third parameter <count>, you can limit the
           output to the first <count> lines, followed by ...  if there are more.

           These parameters can also be set individually with --stat-width=<width>,
           --stat-name-width=<name-width> and --stat-count=<count>.

なのでテキトウに --stat に大きめの数値を渡してあげれば大丈夫かもしれない。

$ git show --stat=100000000 HEAD | grep -F '|' | cut -d '|' -f 1
...
test/mocks/github.com/aws/aws-sdk-go/service/sqs/sqsifacemocks/SQSAPI.go 

まぁ暫定的には。。

--name-status

たまたまmanをのぞいて見つけた --name-status オプションの方が目的にはあっているかもしれない。

$ man git-show
...

       --name-status
           Show only names and status of changed files. See the description of the
           --diff-filter option on what the status letters mean.

以下の様な出力になる。変更されたもの(M)や追加されたもの(A)だけを取り出せるのでこちらのほうが良いかもしれない。

$ git show --name-status HEAD
...
M       xxx/gen/mock/handler.go
M       yyy/gen/mock/router.go
M       zzz/gen/mock/next_state.go
M       aws/s3/gen/mock/s3.go
M       test/mocks/github.com/aws/aws-sdk-go/service/sqs/sqsifacemocks/SQSAPI.go

一応これで大丈夫か調べてみる。

$ git log --stat | grep -F '...' | head -n 1
 .../aws-sdk-go/service/sqs/sqsifacemocks/SQSAPI.go | 122 ++++++++++-----------

$ git log --stat=1000000 | grep -F '...' | head -n 1
$ git log --name-status | grep -F '...' | head -n 1

大丈夫そう。

現在変更されているファイル

ついでに現在変更されているファイルにも使えるか試してみる(git reset HEAD~~ などでテキトウにそういう状況を再現して)。

現在変更されているファイルを対象に一覧したい場合。

$ git diff --name-status

addされたファイルのみを対象にしたい場合。

$ git diff --staged --name-status

というわけで一覧が欲しかったら以下の様な形にすれば良さそう。

$ git diff --name-status | grep -P '^(A|M)\t' | cut -f 2

まとめ

更新されたファイルの一覧が欲しかったら --name-status が便利。git show に限らず色々なコマンドで使える。