2023/04/26

Goのtestifyが自分には大きすぎたので actually

お仕事の Go ではよくstretchr/testify を利用していますが、自分でライブラリを書いてテストを書こうと思ったとき、testify はちょっとでかすぎた。testifyはオールインワンテスティングフレームワークなのでそりゃそうさというところなんですが、それにしてもでかい。みんなそう思わないのだろうか。

あと、有名な「Goはなぜアサーションを持たないか」Why does Go not have assertions? という FAQ が存在するせいか、Goにはあまりアサーションライブラリがない。あるにはあるのだけど、結局 testify に収れんしている。という印象。

NOTE: ここでいう testifyは、testify/assert のことです。その他の機能については触れません。

testifyのアサーションメソッド

というわけでまず、代表的なtestifyの2値アサーションメソッドをあげてみます。

  • Equal
    • 2値の比較は reflect.DeepEqual もしくは bytes.Equal 相当
    • ポインタは中身(値そのもの)を見るだけでアドレス比較はしない
    • funcは受け入れない
  • Exactly
    • Equal + 型の一致をみている(実状としては Equal も型が違うと落ちる)
  • Same
    • ポインタアドレスの一致をみる
  • EqualValues
    • 主に数値比較用で、型が変換可能で値が同じなら pass
  • IsType
    • "Is" とついているが2値比較する
    • reflect.TypeOf で2値の型を比較する
  • EqualExportedValues
    • Struct比較用
    • publicなフィールドと値が一致していれば pass

自分はこれらの機能差分をいちいち覚えていられない。使うたびにソースまで読んだりしてる。みんなこれを覚えているの?

これ以外にも2値アサーションではないけど特徴的なのをあげると Empty というメソッドもあり、いわゆる「空」をみるのだけど、ちょっと抽象的で、実際、型によってそれぞれ「空」の意味がいろいろあってそれを判定していて、これもやっぱり自分は毎回ソースを参照してしまっている。PHPの empty と名前も同じだけど、ふわっとしすぎてる感じも似ている。テストでふわっとする必要ってあるんじゃろか。超便利だけど。

testifyのインターフェース

testifyは2通りの呼び出しをサポートしている。

シンプルな方。

assert.Equal(t, expect, got, "they should be equal")

たくさんアサーションするときは New して t を省略することができる。

assert := assert.New(t)
assert.Equal(expect, got, "they should be equal")

これはまあそういうもんですよね。

なんですけど、このアサーションの引数で Expect/Got を渡すときの順番が自分はめちゃくちゃ気になる。testifyの Expect/Got の順序は、Expectが先(全てではない!これも紛らわしい)なのだけど、自分は Perl Test::More 育ちなので Got を先に書きたくなる。また、実践的に、テストを書いているとき、Expectはほとんど確定していて、Gotをごにょごにょ書いて、いざアサーションとなると、またExpectを先に書かなきゃいけなくなる。さっきまでGotについて書いてやっと手に入れたGotを一度忘れて Expectを書かされる。自分はこれにすごく抵抗を感じる。Gotから書けた方が便利な時だってあるはず。

このExpect/Gotの順番論争というのはけっこういろんなところでされているらしいが、ちょっと調べてみたところ JUnit以降は Expectが先になっているらしい。

assertEqualsやassert_equalの引数はなぜ expected, actual の順なのか、調べてみた

まあ、自分も調べてみた感じ、たしかに Got先のものが圧倒的に少ないなという結論だった。

でも、需要としてはあるのですよ。知らんけど。少なくとも自分にはある。

(なお、expect/gotに対して want/actual、もしくは expect/actual という表現の別もありますが、いったんここではその違いは触れません)

Test Actually

というわけで、testify使ってて便利だけど自分はもっと手になじむ小刀が欲しい!testify V2待ってられない! Don't ask. Just write. という思いを胸に書きました。actually といいます。

2値アサーションは以下のようなメソッドがあります

  • Same
    • testifyでいうところの Exactly
    • 2値の比較は reflect.DeepEqual もしくは bytes.Equal 相当
    • ポインタは中身(値そのもの)を見るだけでアドレス比較はしない
    • funcは受け入れない
    • 型の一致までみる
  • SameNumber
    • testifyのEqualValues
    • 主に数値比較用で、型が変換可能で値が同じなら pass
    • 型を気にせず数値をアサーションしたいときはこっち
  • SamePointer
    • testifyのSame
    • ポインタアドレスの一致をみる

2値アサーションは全て Same という接頭辞をつけていく方針。エディタで補完されやすい。基本的に Same を使えばよい。Sameが strictすぎて実践的に使いづらいところだけ別メソッド(SameNumberSamePointer)で補完する計画。

サンプルコードは以下のような感じです。

Go play ground

package main

import (
    "testing"
    "github.com/bayashi/actually"
)

func Test(t *testing.T) {
    love, err := getLove()
    actually.Got(love).True(t)
    actually.Got(err).Nil(t)
    actually.Got(love).Expect(true).Same(t)
    actually.Got(int32(1)).Expect(float64(1.0)).SameNumber(t)
    heart := &love
    body  := heart
    actually.Got(heart).Expect(body).SamePointer(t)
}

func getLove() (bool, error) {
    return true, nil
}

見ての通り、ビルダーパターンのようなインターフェースです。GotExpect のセッターが分離していて、どっちを先に書いても大丈夫です。もうこれで順番論争からはおさらばです。世界は明示的な方が良いのです。読むときも書くときも、迷わないことの方が価値が高い!

それから、*testing.T は常にアサーションメソッドに渡します。(これを一番最初に渡すのも好きじゃなかった。思考が途切れる)

なお、actually ってタイプするのが長いよって思う人は短いエイリアス(a とか acc がおすすめ)をつけるか、禁断のドットimportするといいと思います。

また、Name("test name") を明示的に使うか、アサーションメソッドの第二引数にテスト名を設定することができます。

actually.Got(love).True(t, "We expect true love.")

Fail actually

さて、ここまで actually のテストコード本体について書きましたが、テストライブラリにはテストコード以上に大事な点が他にもあります。

それは、こけたときのレポートです。

魚釣りは魚が釣れない時間が圧倒的に長いので、釣れない時間を楽しめないと続きません。

同じように、開発者はこけたテストと付き合う時間が圧倒的です。こけたときのレポートがわかりやすく、修正方針のヒントとならなければなりません。

その点、testifyはものすごくよくできています。(モバイルだと表示が崩れるのでPCで見て欲しい)

--- FAIL: Test (0.00s)
    testify_test.go:10:
                Error Trace:    /testify_test.go:10
                Error:          Not equal:
                                expected: 123
                                actual  : 1233
                Test:           Test
                Messages:       comp 123

このまとまって見やすいレポートは、testifyを選ぶ理由の大きなポイントではないかと思います。assertionについていろいろ書いたけど、testify の fail reportはものすごく便利。超便利。testify最高。

なので、私の actually は、testifyの出力をしゅっとコピーしました。やったね! 節操は二の次です。開発を楽にしたいのです。

ただし、ただコピーするだけだと芸がないので、failレポートに必要な機能を独立させて、外から使うことも(一応)できるようにしています。もし、テストライブラリをお書きの際は、ご利用ください。

  • report レポート生成用
  • trace スタックトレース取得
  • diff 2値差分取得

また、複雑なテキスト同士の比較や、入り組んだStructを比較したときにこけた場合、"%#v" によるダンプや diff だけでなく、構造そのもののdump全体が見たいときはありませんか? 私はあります。

そんな時に便利なメソッドも actually は備えています。

actually.Got(a).Expect(b).X().Same(t)

上記のように X() というメソッドを呼んでおくと、こけたときに、以下のようなRawダンプを表示してくれます(以下の例は文字列なので見たままの表示。Structは spew によるダンプが出ます)。データが複雑で違いが複数個所ある場合など、きっと助けになると思います(広い表示領域が必要になりますが)。手動でダンプを埋め込む手間はもう必要ありません。

ちなみに、X は explain と覚えてください。

builder_test.go:133:
            Trace:          /path/to/src/github.com/bayashi/goverview/builder_test.go:133
            Function:       TestTree()
            Fail reason:    Not same
            Expected:       Dump: "\n┌ 001/\n├── .gitignore\n├── LICENSE: License MIT\n├── go.mod: go 1.18\n└───+ main.go: main\n      Func: X\n      const: X\n"
            Actually got:   Dump: "\n┌ 001/\n├── .gitignore\n├── LICENSE: License MIT\n├── go.mod: go 1.19\n└──* main.go: main\n      Func: X\n      Const: X\n"
            Diff Details:   --- Expected
                            +++ Actually got
                            @@ -4,6 +4,6 @@
                             ├── LICENSE: License MIT
                            -├── go.mod: go 1.18
                            -└───+ main.go: main
                            +├── go.mod: go 1.19
                            +└──* main.go: main
                                   Func: X
                            -      const: X
                            +      Const: X
            Expected Raw:   ---
                            ┌ 001/
                            ├── .gitignore
                            ├── LICENSE: License MIT
                            ├── go.mod: go 1.18
                            └───+ main.go: main
                                  Func: X
                                  const: X
                            ---
            Got Raw:        ---
                            ┌ 001/
                            ├── .gitignore
                            ├── LICENSE: License MIT
                            ├── go.mod: go 1.19
                            └──* main.go: main
                                  Func: X
                                  Const: X
                            ---

やっぱり、百聞は一見に如かずですよね。気持ちいい。

というわけで、以上、小さなassertionライブラリ actually の紹介でした。

「Goはなぜアサーションを持たないか」Why does Go not have assertions? にある通り、error handling と reporting を適切に書くことは何よりも重要ですが、人生は有限ですし、特にチーム開発をする場合に全員が全員 error handling と reporting を適切に "一貫性のある" 書き方ができるかというと、なかなか難しいのが現実だなあというのが自分の印象です。特に、こけたときのレポーティングに関してはテストを書くという本質とは離れてくる気がして、各実装者が頑張る必要は無いというのが自分の意見です。なぜこのテストが存在するのか、なぜこの2値比較なのかといった意義や、Goの型やゼロ値、または充分なカバレッジを果たすテストケース群というのは、実装者(開発チーム)の責務だと思います。

actually はいまのところ v1 以前ですし、俺の俺による俺のためのライブラリにしかなってないですけど、もしよかったらお試しください。また、ご意見や機能要望もお待ちしております。

ではでは。

サイト内検索