テストマッチャライブラリ its の開発を続けているという話
こんにちわ youta-t です。
初投稿 以来およそ10日たちました。
あれからもコツコツ its の開発を進めています。ときどきドッグフーディングしたりしながら、機能を追加したりバグをとったりしています。機能拡張を繰り返して、バージョン番号は v0.2.5 になりました。
今日は「10日弱で its はこうなりました」という話をします。
そもそも its とは
golang で書かれた、golang 用のテストマッチャライブラリです。
テストするときに苦痛 なのは 「巨大な struct や json の比較をするとき」 と 「モックつかうとき」 ですよね。
its はそうした場面でもテストが楽になるように設計されています。もちろん、もっと単純なテストもどんどん書いていけますよ!
its で簡単なマッチをしてみる
its でのマッチは、こんなかんじです。
func TestFoo(t *testing.T) {
want := 42
got := 24
its.EqEq(want).Match(got).OrError(t)
}
比較の方法ごとに its.なんとか
という関数が用意されているので、それを使っていきます[1]。
たとえば its.EqEq
であれば、 comparable な値を ==
(eqeq)で比較して、一致すればマッチ!というマッチャです。
マッチャの Match
メソッドに実際に得た値(got
)を渡して、さらに .OrError(t)
すると、テストはもう書けています[2]。
さて、上記の例は、明らかに test が fail すべきですね。どうなるか見てみましょう。
=== RUN TestExample
prog_test.go:12:
✘ /* got */ 24 == /* want */ 42 --- @ /tmp/sandbox3416529050/prog_test.go:12
--- FAIL: TestExample (0.00s)
FAIL
...と、このようなメッセージが書き出されます。
-
its.EqEq
に渡したwant
(期待する結果)とgot
が埋め込まれていますね。 - さらに、マッチャを書いた箇所が書き出されています。
これで、ひとつのテスト関数でたくさんマッチをとっても、どこが問題なのかすぐにたどれます。
最近の差分
リリース以後もマッチャは増えています。増えたものは次のものたちです。
-
nil
を検証するマッチャits.Nil[T]
- テキストの文面が不一致だったら diff を表示してくれるマッチャ
its.Text
もっと複雑なマッチをしてみる
its のマッチャは "合成" できます。合成とは? 見ていきましょう。
func TestExample(t *testing.T) {
its.All(
its.GreaterThan(10),
its.LesserThan(20),
).
Match(30).OrError(t)
}
its.All
が出てきました。これはマッチャを引数にとって、「すべてのマッチャがマッチしたときにだけ、マッチする」マッチャを返します。
この例では、「10より大きい」かつ「20より小さい」ときにだけ、すなわち
今回もテストが失敗しています。ログを見てみましょう。
=== RUN TestExample
prog_test.go:14:
✘ // all: (1 ok / 2 matchers) --- @ /tmp/sandbox567676380/prog_test.go:10
✔ /* want */ 10 < /* got */ 30 --- @ /tmp/sandbox567676380/prog_test.go:11
✘ /* want */ 20 > /* got */ 30 --- @ /tmp/sandbox567676380/prog_test.go:12
--- FAIL: TestExample (0.00s)
FAIL
成功してる方(✔
)と失敗してる方(✘
)がそれぞれ別に表示されていますね。
こうなっていれば、 ✘
がついているところにフォーカスしてバグ取りをしてゆけます。
slice や map のマッチャもこうしたメタマッチャとして表現されています。各要素がマッチするか? を検証するようになっています。
最近の差分
最初のリリース以後、 its.Pointer
というメタマッチャが増えました。
its.Pointer[T](matcher).Match(got)
は「*T
型の got
が nil
ではなく、さらにポインタの指す先の値が matcher
にマッチする」ときに、マッチするものです。
つぎに示しますが、 適当な struct のマッチャから「struct へのポインタ」へのマッチャを導出できます。
もっと厄介なケース: struct
さて、 struct の話をしましょう。
テストをしていてヤバいシーンのひとつは、 struct が出てきたときですよね。
たとえば、こんなことやっちゃった日には......
type LargeStruct struct {
Field1 int
Field2 string
Field3 bool
Field4 float64
}
func TestExample(t *testing.T) {
want := LargeStruct{
Field1: 120,
Field2: "brabrabra...",
Field3: false,
Field4: 99.125,
}
got := LargeStruct{
Field1: 120,
Field2: "brabrabra.....",
Field3: false,
Field4: 99.125,
}
if want != got {
t.Errorf("%+v != %+v", want, got)
}
}
ログがこうなって...
=== RUN TestExample
prog_test.go:31: {Field1:120 Field2:brabrabra... Field3:false Field4:99.125} != {Field1:120 Field2:brabrabra..... Field3:false Field4:99.125}
--- FAIL: TestExample (0.00s)
FAIL
"目diff" と "目grep" に時が溶けてゆくことになります。
しかも、この例はまだかわいい方です。 LargeStruct
は comparable にできたので、 ==
で一致してるかどうかだけはわかりました。もし comparable じゃないときには...... フィールド一個づつ比較することになるでしょう。悲惨なことになります。
its をつかって、この苦痛から逃れましょう。
its には、struct 用のマッチャを生成するジェネレータ github.com/youta-t/its/structer
が同梱されています。
まずはコレをつかって、 struct のマッチャを生成しましょう。
//go:generate go run github.com/youta-t/its/structer
package example
type LargeStruct struct {
Field1 int
Field2 string
Field3 bool
Field4 float64
}
で、テスト側。
package example_test
// ...
func TestExample(t *testing.T) {
want := gen_structer.LargeStructSpec{
Field1: its.EqEq(120),
Field2: its.EqEq("brabrabra..."),
Field3: its.EqEq(false),
Field4: its.EqEq(99.125),
}
got := LargeStruct{
Field1: 120,
Field2: "brabrabra.....",
Field3: false,
Field4: 99.125,
}
gen_structer.ItsLargeStruct(want).Match(got).OrError(t)
}
こんな感じに仕様を書き下せるようになります。
さて、間違っているのはどこかな?
--- FAIL: TestExample (0.00s)
/path/to/example_test.go:29:
✘ type LargeStruct: --- @ /path/to/example_test.go:29
✔ .Field1 :
✔ /* got */ 120 == /* want */ 120 --- @ /path/to/example_test.go:16
✘ .Field2 :
✘ /* got */ brabrabra..... == /* want */ brabrabra... --- @ /path/to/example_test.go:17
✔ .Field3 :
✔ /* got */ false == /* want */ false --- @ /path/to/example_test.go:18
✔ .Field4 :
✔ /* got */ 99.125 == /* want */ 99.125 --- @ /path/to/example_test.go:19
FAIL
FAIL github.com/youta-t/its-example 0.256s
FAIL
はい、Field2
でしたね。一目瞭然です。
モック
これは初回リリースよりも後に増えた機能[3]です。
go のテストでモックしたいものというと、関数と interface ですね。its はそれらに対するモックジェネレータ github.com/youta-t/its/mocker
を備えています。
たとえばこんな関数型があるとしましょう(セッションストアからルックアップするような機能を想定しています)。
//go:generate go run github.com/youta-t/its/mocker
// ...
type GetSession func(cookie string) (userId string, ok bool)
すると、このモックはこういう風に書けるようになります。
getSession := gen_mock.NewGetSessionCall(
its.EqEq("some-cookie-value"),
).
ThenReturn("user_id", true).
Mock(t) // t は *testing.T
New...Call
という関数が生成されて、コレが関数モックの起点になります。この引数は、「呼び出されたときの各引数に対するマッチャ」を渡してください。すると、呼び出されるたびにマッチャが試されます。もしマッチできなければ、エラーメッセージが書き出されることになります。
.ThenReturn
は文字通り、呼び出されたときの戻り値を決めています[4]。
.ThenReturn
の戻り値は当然値として持ち回れるので、table driven test のテーブルにもたせておくこともできちゃいます。
インタフェースもモックできます。こんなインタフェースがあったとして
//go:generate go run github.com/youta-t/its/mocker
// ...
type UserRepository interface {
Get(userId string) (User, error)
}
これを mocker に通すと、モックを次のようにして手に入れられます。
gen_mock.NewMockedUserRepository(t, gen_mock.UserRepositoryImpl{
Get: gen_mock.NewUserRepository_GetCall(
its.EqEq("user_id"),
).
ThenReturn(User{ /* ... */ }, nil).
Mock(t),
})
...Impl
の各フィールドに適当に関数をセットしたものを、 New...
に渡せばいいだけです。
関数は、シグニチャの合ってる関数ならなんでもいいので、当然にモック関数でオッケーです。
インタフェースの各メソッド用にも、モック関数ビルダが生成されています。
シナリオテスト
さて、関数やインタフェースをモックしたとき、ときどき足をすくわれるのが「呼び出されていない」問題ですね。
マッチャが関数の中にあるので、呼び出してもらえないと単に無視されるだけです。テストはパスしてしまいますが、望ましい姿ではないでしょう。
its は、関数の呼び出される順番を「シナリオ」としてテストできるような機能を備えています。
// ...
import "github.com/yotua-t/its/mocker/scenario"
// ...
func TestExample(t *testing.T) {
sc := scenario.Begin(t)
defer sc.End()
// ...
}
こうやっておくと、
-
sc
に登録した順番に関数が呼び出されないと、テストがエラーする - テストが終わるまでの間に
sc
に登録したが呼び出されていない関数があると、テストがエラーする
という「シナリオ」を作ることができます。
シナリオに関数を登録するには、次のようにします。
// ...
import "github.com/yotua-t/its/mocker/scenario"
// ...
func TestExample(t *testing.T) {
sc := scenario.Begin(t)
defer sc.End()
_, getSession := scenario.Next(
sc,
gen_mock.NewGetSessionCall(
its.EqEq("some-cookie-value"),
).
ThenReturn("user_id", true).
Mock(t),
)
// ...
}
こうすると、シナリオによって追跡されたバージョンの getSession
が手に入ります。
あとはこれをテスト対象に inject するなり、インタフェースモックの実装としてセットするなりしてやれば「意図しない順で呼び出されていた」とか「そもそも呼ばれてなかった」とかを心配しないでよくなります。
最近の変更
微妙な変更ですが、モック関数ビルダーの ThenReturn
が返す型を、生成された構造体から、そのポインタにしました。
table driven test のエントリに仕様の一部としてモック関数ビルダーを組み込む場合、時々「このケースでは、この関数は呼ばれないべきだ」という場合があります。
そうしたときでも、 nil
にしておけるようになりました。これで scenario.Next
に渡さないように条件チェックできますね。
というライブラリになってます
今後もしばらくはドッグフーディングを続けるつもりです。バージョンアップをお楽しみに!
-
完全な一覧は pkg.go.dev へどうぞ。全マッチャに example がついています。 ↩︎
-
.OrError(t)
は、マッチに失敗していたときだけ、t.Error
を呼び出す、という振る舞いをしています。 ↩︎ -
このときは、記事を起こしました。 https://zenn.dev/youta_t/articles/30b8e7236a0fc7#モック生成機能 ↩︎
-
なにかモック内で副作用を及ぼしたい場合のために、
.ThenReturn
の代わりとして.ThenEffect
が使えます。この場合、.ThenEffect
に渡した関数の戻り値がモック関数の戻り値になります。 ↩︎
Discussion