go用のテストライブラリを作りました(言いたいことは分かります)

github.com

go用のテストライブラリを作りました(言いたいことは分かります)。

使いかたはこんな感じです。

noerror.Must(t, noerror.Equal(30).Actual(add(10,20)))

noerror.Should(t, noerror.NotEqual(30).Actual(add(10,20)))

以下詳細。

motivation

元気があるときにはtestingパッケージをそのまま使うのが良いという事は分かるし、その方が見通しも良くなることもあるということも分かっています。ただ、元気が無いときやまたは「我gopherぞ?」みたいな自尊心に溢れていない時にはついつい面倒になってしまうんですよね。ifは許容範囲なんですがSprintf形式の文字列を書くのがめんどくさい感じです。

個人的には、特にリリース対象ではないコードや日常的な調査用のメモのコードでも結果を確かめるためにテストコードを書く事があるんですが、そういう時に面倒に感じたりしてます(テストコードが必要な理由は頭があまり良くないせいかもですね。うかつなんです。そういうことにしておきましょう)。

そんなときに今までtestifyを使っていたんすが、これは欲しいことに対して機能が多すぎるかなーと思ったりしてました。

github.com

testifyは色々比較用の関数(matcher)がたくさんあるんですが覚えきれないし結局使うのは一部だけ。加えてほかの環境でも常に利用したいかと想うかというとそうでもない。なので網羅して覚えようというモチベーションも少なめでした。。

そんなわけでちょっとしたassertionライブラリが欲しかったんです。

Sometimes, simple is too strict, and easy is too complicated

イメージ的にはそんな感じです。

concepts

せっかく作るんでもうちょっとまじめにコンセプトを考えてみようかなと思いました。

考えてみると、実質エラーチェックだけできれば十分で、細かな記述なしにそれなりの形でエラーメッセージをつくってくれれば十分かもと思ったりしました。

そんなわけで、以下がコンセプト。

  • testing library is error check library
  • zero dependencies

エラーチェックだけできれば十分なのではというのは仮説です。 (依存がゼロというのは趣味みたいなもので省略します)

ついでにtestifyを使っていてちょっと嫌だなーと思う部分にも対応することにしました。

作っているときに考えていたこと

ここからは作っている時に考えていたことを詳しく。

testing library is error check library

テスト用のライブラリで必要なのはエラーチェックだけで十分なのでは?というのはこういうことです。通常testingパッケージを直接使って手書きしたようなコードでは以下の様なコードになると思います。

// testingをそのまま使ったコード

if got := add(10, 20); got != 30 {
    t.Errorf("expected %d, but actual %d", got)
}

if文はともかくErrorfの方がめんどくさいわけですね。一方testifyを使ったコードはこうなります。

// testifyを使ったコード

assert.Exactly(t, 30, add(10,20))

これは短いのですが、個別にmatcherが定義されていて、これを適宜使い分けたり存在を認識するのがめんどくさかったりします。元々の目的が日常的な書き捨て用途のものだったりするので(記憶が確かならYAMLなどにも依存するのでgo.modを汚す感じなのも気分があんまり良くなかったりします)。

今回作ったライブラリを使ったコードではこうなります。

// noerrorを使ったコード(今回作ったライブラリ)

noerror.Should(t, noerror.Equal(30).Actual(add(10,20)))

testifyより長くはなります。

エラーチェック

そしてassertionは結局エラーチェックだけで良いということの意味なのですが、先程のコード(再掲)のEqual()は問題があった時にエラーを返すような関数です。そしてShould()はエラーが渡されたときだけ反応する関数です。

noerror.Should(t, noerror.Equal(30).Actual(add(10,20)))

これらをそれぞれassertion関数、comparison関数と呼んでいます。一般的には以下の様な形で利用されます。

// t is *testing.T
// want is expected value
// got is actual value

<Assertion>(t, <Comparison>(want).Actual(got))

つまりassertion関数はエラーを受け取ってよしなにするだけ、そしてcomparison関数は何かを比較してエラーを返すだけの関数です。これでおしまい。

assertion関数

実際定義されているassertion関数は以下の2つだけです。

  • Must()
  • Should()

Must()はtestifyのrequire.NoError()に対応し、エラーを受け取ったらt.Fatal()を呼び出します。 Should()はtestifyのassert.NoError()に対応し、エラーを受け取ったらt.Error()を呼び出します。

これでおしまいです(実際にはログ用のLog()も定義されていたりします)。エラーが発生した場合は以下の様なエラーメッセージになります。

Equal, expected 10, but actual 2

ちなみにエラーメッセージのフォーマット自体はあまり真剣に考えたものではないのでご意見ご要望をお待ちしてます。

少しおもしろいのはエラーを返す関数のテストは以下のようにも書けるところです。まぁ意味合いとしてはNoError(t, err)なので。。doSomething()はエラーを返すような関数です。

noerror.Must(doSomething())

以下のようなメッセージがテストに失敗したときに出力されます(pkg/errorsによるスタックトレースの表示にも一応対応しています)。

unexpected error, *ERROR*

このコードの意味が分かるようにすることとEqual()などのcomparison関数の名前との対応を考えたときのパッケージ名でけっこう頭を悩ませたりしました。noerrorがmustだとなんとなく意味は通じそうな感じです。

(実は最初はeasyとsimpleのちょうど良い塩梅ということを現したくてhandyという名前でした。handy.Must(t, err)だと意味がわからないですよね)

comparison関数

comparison関数は以下の6つです。意味は関数名からなんとなく解ると思います。JSONEqualだけ特別かもです。それぞれ=でstrictに比較するもの、reflect.DeepEqual()でゆるく比較するもの、JSON形式に変換してから再変換をかけてよりゆるく比較するものの3種類です。

  • Equal()
  • NotEqual()
  • DeepEqual()
  • NotDeepEqual()
  • JSONEqual()
  • NotJSONEqual()

これだけなのでわりと見通しが良いです。

True(), False(), Nil()みたいなものを定義していこうとするとやがてtestifyになってしまうということでこれ以上増やすのは止めました(floatのような微小な差分が出てしまいそうなものやunorderedなslicesなどは頑張ってください)。

Actual(<value>)

ここからはtestifyで気になったことについても。

まず、testifyを使った時に気になった部分のひとつ目は比較対象の値がどちらの目的で使われているのかわかりにくい点です。

  • expected value (期待する値, 正答例, 解答)
  • actual value (実際の値, 回答)
// 両者の比較

assert.Exactly(t, 30, add(10,20))

noerror.Should(t, noerror.Equal(30).Actual(add(10,20)))

実際にはgodocを読めば assert.Exactly(t, expected, actual) ということが分かるのですが、そういうことを考えたくない気持ちがありました。

一方でnoerrorの場合は引数として渡す時にそもそも同時に渡すことをやめて、Actual(<value>) 形式でだけactual valueを指定するようにしました(testingパッケージ直打ちの場合には変数名とErrorfに渡すメッセージの部分でどちらか分かるようにしてますよね。当然)。

ちなみに実装の途中でActual()代わりにExpected()を利用するようにすると後で面倒な話になりそうと言うことが分かったりしました(これは後で説明します)。

Describe()

エラーメッセージのときにちょっとだけ文脈を追加したいですよね。さすがにエラーメッセージが以下だけだと分かりづらい。

Equal, expected 10, but actual 2

Describe()を使うとメッセージの先頭部分を変えられます。

noerror.Should(t, noerror.Equal(10).Actual(1 + 1).Describe("1 + 1"))

結果はこう変わります。

1+1, expected 10, but actual 2

rest arguments

もうちょっと情報を付加したいことがあると思います。例えばweb APIのテストを書いている時にstatusが200を期待するテストで間違ったrequestになってしまったときなど。修正後のテストコードでは問題にならない部分なのですが、テストの修正中にはエラーレスポンスのbody自体が確認できると便利だったりします。400でBad Requestのときのエラーレスポンスにエラーの原因が書かれていたりすることがあるので(printデバッグをたまにしちゃったりもします)。

そんなわけでもう少し情報を余分に付加したくなります。これはtestifyに揃えてShould()などが余分に引数を取れるようにしました。

noerror.Should(t, noerror.Equal(11).Actual(10), ":bomb:")

は以下の様な表示になります。

Equal, expected 11, but actual 10
:bomb:

実はここの部分と先程のエラーレスポンスの中身を表示したいという要求の相性が良くなかったりします。これはtestifyにも関連することなんですが、testingの場合にはif文で分岐されるので一度消費済みのio.ReadCloserが渡されることはないんですが(もちろん無いようにテストコードを書いてますよね?)、ここをeagerに評価してしまうと予期せぬ消費が行われて後のコードでエラーになるみたいなことが発生します。似たような機能を作るときにはlazyにしましょう。

今回の実装ではfmt.Stringerを実装したものならそちらを使う様になってます。なので以下の様なstructがたまに役に立つかもです。

type lazyString struct {
    toString func() string
}

func (x *lazyString) String() string {
    return x.toString()
}

ちなみに最初はfunctional optionsのような形で提供していたんですけれど。邪魔に感じたんですよね。Describe()も含めて。そんなわけでDescribe()はメソッドにこちらは可変長引数にして対応することにしました。

ボーナスポイント

ここでボーナスポイントです。昔流行った表現で言えばOne more thingと言うやつです。

戻り値として多値でエラーを含んで返す関数があったりしますがこういう関数へのassertionがtestifyでは厄介でした。これを一行で書けるようになってます。

こういうやつですね。

// Count() returns error
Count := func() (int, error) { return 0, fmt.Errorf("*ERROR*") }

c, err := Count()
require.NoError(t, err)
assert.Exactly(t, c)

1行で書ける様になってます。ActualWithError()です。

noerror.Should(t, noerror.Equal(0).ActualWithError(Count()))

実際にはちょっとだけ特殊なことをしていて、Must()を使っていようがShould()を使っていようが多値を返す関数でエラーがかえって来たらt.Fatal()が呼ばれます。途中で実行をストップします。

大抵の場合、この種のコードがテストコード上に現れるのは、テスト対象の実行後のDBの整合性チェックだったりするので。

ちなみにActual()のかわりにExpected()を定義する様に実装していくとcomparison関数の種類が膨れ上がります。ツライですね(伏線の回収)。

さいごに

今回はREADMEなどをけっこうまじめに書いたのですが英語があまり得意ではないのでPRなどお待ちしております。もちろん機能要望なども。

ついでにgoのテストについてどこかでだれかと話したりしたいな−と思ったりしています(もちろん不満だーだるいーというような感情の発露みたいな感じではなく、郷に従えーとかgo wayとはーで説教するされるという感じではなく)。