🦔

its: v0.2.0 をリリースした

2024/02/14に公開

こんにちわ、youta-t です。
テストマッチャライブラリ its ( github.com/youta-t/its ) を作っています。

こういうテストケースの...

func TestUgry(t *testing.T) {
	got := User{
		Id:    "example-user",
		Name:  "John Doe",
		EMail: "doe.j@example.com",
	}

	want := User{
		Id:    "example-user",
		Name:  "Jane Doe",
		EMail: "doe.j@example.com",
	}

	if got != want {
		t.Errorf("user:\n===got===\n%+v\n===want===\n%+v", got, want)
	}
}

こういう結果がつらいので(目diff!!!!)...

--- FAIL: TestUgry (0.00s)
    /....../example_test.go:27: user:
        ===got===
        {Id:example-user Name:John Doe EMail:doe.j@example.com}
        ===want===
        {Id:example-user Name:Jane Doe EMail:doe.j@example.com}

代わりに、こういうふうにテストを書いて...

func TestNice(t *testing.T) {

	got := User{
		Id:    "example-user",
		Name:  "John Doe",
		EMail: "doe.j@example.com",
	}

	want := UserSpec{
		Id:    its.EqEq("example-user"),
		Name:  its.EqEq("Jane Doe"),
		EMail: its.EqEq("doe.j@example.com"),
	}

	ItsUser(want).Match(got).OrError(t)
}

こういう結果を手に入れよう、ってライブラリです。

--- FAIL: TestNice (0.00s)
    /....../example_test.go:45: 
        ✘ type User:		--- @ /....../example_test.go:45
            ✔ .Id :
                ✔ /* got */ example-user == /* want */ example-user		--- @ /....../example_test.go:40
            ✘ .Name :
                ✘ /* got */ John Doe == /* want */ Jane Doe		--- @ /....../example_test.go:41
            ✔ .EMail :
                ✔ /* got */ doe.j@example.com == /* want */ doe.j@example.com		--- @ /....../example_test.go:42

一目瞭然! どうぞご贔屓に!

さてさて、今日は、新機能を搭載した新バージョン v0.2.0 のご紹介です!

新機能

v0.2.0 の新機能は 2 つ!

  1. エラーログに「エラーを発生させたマッチャがどこで作られたものか」が載るようになった
  2. モック生成機能

順に見ていきましょう。

エラーログに「エラーを発生させたマッチャがどこで作られたものか」が載るようになった

実は冒頭で示したエラーログは、この機能が反映されていました!

これまで、このテストコードに対して...

func TestSlice(t *testing.T) {

	its.Slice(
		its.LesserEq(3),
		its.EqEq(4),
		its.GreaterEq(5),
	).
		Match([]int{1, 4, 4}).
		OrError(t)
}

エラーログの内容はこんな雰囲気でした。

--- FAIL: TestSlice (0.00s)
    /....../example_test.go:23: 
        ✘ []int{ ... (len: /* got */ 3, /* want */ 3; +1, -1)
            ✔ /* want */ 3 >= /* got */ 1
            ✔ /* got */ 4 == /* want */ 4
            ✘ - /* want */ 5 <= /* got */ ??
            ✘ + /* got */ 4

たしかに、何がどう間違っているのか、ということはわかるんですが、コレだとちょっと物足りないですよね。
何が足りないかというと、「どこでミスってるか」の情報が、ここにはありません。
t.Error が呼び出された場所、というレベルではわかるのですが、どの「✘」がどのマッチャに由来するのか、という情報はありませんでした。

v0.2.0 からは、各マッチ結果に「そのマッチをしたマッチャの、生成された行数」、言い換えれば 「仕様を宣言した場所」が出力される ようになりました。

これからは、こういう雰囲気になります。

--- FAIL: TestSlice (0.00s)
    /....../example_test.go:25: 
        ✘ []int{ ... (len: /* got */ 3, /* want */ 3; +1, -1)		--- @ /....../example_test.go:19
            ✔ /* want */ 3 >= /* got */ 1		--- @ /....../example_test.go:20
            ✔ /* got */ 4 == /* want */ 4		--- @ /....../example_test.go:21
            ✘ - /* want */ 5 <= /* got */ ??		--- @ /....../example_test.go:22
            ✘ + /* got */ 4

これで問題のマッチャに即ジャンプできるようになりました🚀

モック生成機能

its に新たなる go generate 機能が追加されます。
github.com/youta-t/its/mocker といいます。

こいつは、//go:genearteしたファイルにあるインタフェース(type ... interface)や関数(type ... func)のモックビルダーを生成する、という代物です。

生成されるモックビルダーについて

ところで、モックを使いたいときってどんなときでしょう?

  • 引数が正しく渡されているか確認したい
  • 戻り値を強制したい
  • DI的に inject した関数やインタフェースが、期待通りの順番で一通り呼び出されていることを確認したい
  • 逆に、インタフェースの意図しないメソッドは呼び出されないことを確認したい

...だいたい、こういう場合じゃないでしょうか。

its のモックは、こういう目的にフォーカスしたものになっています。

関数のモックビルダー

関数のモックでは、「引数が正しいこと」と「戻り値を強制すること」を実現します。

仮に、次の関数型をモックしてみることにしましょう。

type GetSession func(cookie string) (userId string, ok bool)

はい、cookie を受け取って、それに対応付けられているユーザIDを取ってくる... というような振る舞いを表現した関数だと思ってください。

これが書かれたファイルに

//go:generate go run github.com/youta-t/its/mocker

と書いて go generate すると、こんな事ができるようになります:

var mockFn GetSession = gen_mocker.GetSessionCall(its.EqEq("fake-cookie"))   // (1)
    .ThenReturn("example-user-id", true)  // (2)
    .Mock(t)  // t は *testing.T  // (3)

まず (1) の行で、引数の仕様を定めています。
生成された Get...Call の引数は、元の関数型の各引数を its.Matcher で包んだものになっています。こうして生成されたモック関数は、呼び出されるたびに与えられたマッチャで引数を検証します。
もしマッチしなければ、テストのエラーログが書き出されることになっています。

続いて (2) の行では、戻り値を決めています。このビルダーで生成されたモック関数は、常にここに渡された組の値を返します。

最後に (3) の行で、モックの構築を終えて、モック関数を完成させています。

「引数をテストする」「返り値を決定する」の両方をさっくり書き下すことができました。

おや、モックに振る舞いが必要ですか? 大丈夫、サポートしていますよ。

こういう事もできるようになっています。

var mockFn GetSession = gen_mocker.GetSessionCall(its.EqEq("fake-cookie"))
    .ThenEffect(func(string)(string, bool) {
        // ... なんか必要な処理 ...
        return "example-user-id", true
    })
    .Mock(t)  // t は *testing.T

ThenReturn の代わりに ThenEffect を使うと、コールバックを挟めます。
このコールバックは、引数を確認した後に呼び出され[1]て、その戻り値がモック関数全体の戻り値になるようになっています。
もちろんコールバックの引数はモックが受け取った引数です。

インタフェースのモックビルダー

レイヤードアーキテクチャだ、DIだ、ってやるのはいいんですけど、作った interface をモッキングするのはダルいですよね。
やることといえば結局、メソッドに対応した func を持ってる struct をつくって、 interface を満たすようにするだけです。

これはあまりにダルいので、自動化しておきました。

type UserRepository interface {
    Get(userId string) (User, error)
    Update(User) error
    Delete(User) error
}

...というインタフェースがあると思ってください。

そうすると、its-mocker が生成するコレのモックビルダーは、こんな感じに使えます。

mockRepo := gen_mocker.NewMockedUserRepository(t, // t は *testing.T
    gen_mocker.UserRepositoryImpl{
        Get: func(userId string) (User, error) { ... },
        Update: func(User) (error) { ... },
    },
)

コード生成で作っているので、元のインタフェースの元のメソッドの型にキチっと揃っています。

NewMocked{{interface name}} に、(*testing.Tとともに)その実装にあたる {{interface name}}Implを渡してやると、モックされたインタフェースの出来上がりです。

Impl 側は、呼び出すつもりのないメソッドについてはセットする必要がありません。
むしろ、セットされていないメソッドが呼び出されるとテストエラーとして報告される[2]ので、セットしないほうがよいです。

これによって「インタフェースの意図しないメソッドは呼び出されないことを確認したい」という目的を達します。

ところで、...Implには適当に関数を渡しておけばいいのでした。its-mocker は、インタフェースのメソッドごとにも関数モックビルダーを生成しています。したがって、こういうことができます。

mockRepo := gen_mocker.NewMockedUserRepository(t, // t は *testing.T
    gen_mocker.UserRepositoryImpl{
        Get: gen_mocker.NewUserRepository_GetCall(
            its.EqEq("example-user-id"),
        ).
            ThenReturn(
                User{
                    Id: "example-user-id",
                    Name: "John Doe",
                    EMail: "doe.j@example.com"
                },
                nil,
            ).
            Mock(t),
        Update: ...
    },
)

こうしておけば、「この mockRepo は、Get を呼び出される予定があり」かつ「呼び出されたなら、所定の引数であるべき」ということが示せます。

シナリオのテストビルダー

ここまでで関数とインタフェースのモックを見てきました。
しかし、まだモックを使ったテストで確認したいことは全てではありません。
「DI的に inject した関数やインタフェースが、期待通りの順番で一通り呼び出されていることを確認したい」が残っていますね。

特に、呼び出されないモック関数が残っていると、その中のチェックはすべてスキップされてしまい、黙ってテストが壊れている、ということになりかねません[3]

そこで、「呼び出すつもりの関数」を順番に宣言していく、いわば「シナリオ」のあるテストを作れるようにしました。

この機能は github.com/youta-t/its/mocker/scenario をインポートすると[4]使えます。

シナリオテストは、次のように書いてゆきます。

まず、こういう関数があるとします。

func UpdateUser(
	sess GetSession,
	registry UserRepository,
) func(cookie string, newName string) error {
	return func(cookie, newName string) error {
		userId, ok := sess(cookie)
		if !ok {
			return errors.New("you are not logged in")
		}
		user, err := registry.Get(userId)
		if err != nil {
			return err
		}
		user.Name = newName
		return registry.Update(user)
	}
}

web api 風のものをイメージしてみました。この関数は、 GetSessionUserRepository を受け取って、リクエストハンドラ(っぽいの)を作って返す、ということになっています。テスト対象は、リクエストハンドラですね。

リクエストハンドラの中では、

  1. セッション情報を取り出してユーザIDを割り出す
  2. それに基づいてユーザ情報を得る
  3. ユーザの名前を書き換えて、記録する

という流れで処理が進むことになっています。

では、テストを書いていきましょう!

    sc := scenario.Begin(t)  // (1)空っぽのシナリオを作る。 t は *testing.T。
    defer sc.End()  // (6) シナリオに呼び出し漏れがないか確認する。

    _, sess := scenario.Next( // (2) 最初に呼ばれる関数を宣言する
        sc,
        gen_mocker.GetSessionCall(its.EqEq("fake-cookie")).
            ThenReturn("example-user-id", true).
            Mock(t)  // t は *testing.T
    )

    _, getUser := scenario.Next(  // (3) その次に呼ばれる関数を宣言する
        sc,
        NewUserRepository_GetCall(its.EqEq("sample-user-id")).
            ThenReturn(
                example.User{
                    Id:   "example-user-id",
                    Name: "John Doe",
                    EMail: "doe.j@example.com"
                },
                nil,
            ).
            Mock(t),
    )

    _, updateUser := scenario.Next(  // (4) その次を登録
        sc,
        NewUserRegistry_UpdateCall(
            ItsUser(UserSpec{
                Id:   its.EqEq("sample-user-id"),
                Name: its.EqEq("Jane Doe"),
                Email: its.EqEq("doe.j@example.com")
            }),
        ).
            ThenReturn(nil).
            Mock(t),
    )

    registry := NewMockedUserRegistry(t,  // interface のモックを作る
        UserRegistryImpl{
            Get:    getUser,
            Update: updateUser,
        },
    )

    testee := example.UpdateUser(sess, registry)
    got := testee("fake-cookie", "Jane Doe") // (5)

    its.Nil().  // error を返していないか? 
        Match(got).OrError(t)

......と、こんな風になります。

いくらかやっていることがあります(が、大半はもう見てきたもののはずですね)。

最初に、シナリオを作り出しています(1)
そのあと、 scenario.Next に関数を呼び出す順に登録していきます(2), (3), (4)

あとは、シナリオから返された関数をつかってインタフェースのモックやテスト対象を構築して、テスト対象を実行しています(5)

最後に sc.End() を呼び出して(6)、呼び出し漏れがないかチェックしています。

これで、「モックを使うテスト」で確認したいことが、一通り確認できました!

まとめ

...ということで、 its v0.2.0 の新機能のご紹介でした。

試したよ!という方は、コメント(や issue)をいただけますと幸いです。

脚注
  1. たとえマッチが失敗していたとしても呼び出されます。 ↩︎

  2. パニックしません、ということでもあります。 ↩︎

  3. だから三角測量しようね、という話はあります。 ↩︎

  4. its をインストールすれば、使えるようになっているはずです。 ↩︎

Discussion