📆

テストマッチャライブラリ its の開発を続けているという話

2024/02/23に公開

こんにちわ 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より小さい」ときにだけ、すなわち 10 < \text{got} < 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 型の gotnil ではなく、さらにポインタの指す先の値が 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 に渡さないように条件チェックできますね。

というライブラリになってます

今後もしばらくはドッグフーディングを続けるつもりです。バージョンアップをお楽しみに!

脚注
  1. 完全な一覧は pkg.go.dev へどうぞ。全マッチャに example がついています。 ↩︎

  2. .OrError(t) は、マッチに失敗していたときだけ、 t.Error を呼び出す、という振る舞いをしています。 ↩︎

  3. このときは、記事を起こしました。 https://zenn.dev/youta_t/articles/30b8e7236a0fc7#モック生成機能 ↩︎

  4. なにかモック内で副作用を及ぼしたい場合のために、.ThenReturn の代わりとして .ThenEffect が使えます。この場合、.ThenEffectに渡した関数の戻り値がモック関数の戻り値になります。 ↩︎

Discussion