🎉

Go言語でテストマッチャライブラリ its を書いた

2024/02/09に公開

はじめまして。 youta-t です。

Java とか Python とか触ってきましたが、ここ1年くらいは Go で開発してます。よろしくおねがいします。

さて、ソフトウェア開発につきものなのがテストです。
Go のテストは... ただテストである以上に簡単じゃない、と思うんですよね。

  1. エラーメッセージは全部手作りすることになる:
    • いちいちメッセージ考えて t.Errorf するのは、地味に骨が折れます。
  2. データとアルゴリズムが離れすぎる:
    • Table Driven Test もいいんですが、Table が大きく育つとデータと処理の間の見通しが悪くなりがちです。
    • 様々な条件分岐を Table Driven Test に埋め込もうと思うと、Table の側もフラグだらけになるし、テストの側も分岐だらけになって、テストそのものがバグります。
  3. struct の比較に、標準がない:
    • == で比較できるものもあれば、Equal があるものもあり、なにもないものもあります。
    • こういう状況なので、 reflect.DeepEqual に丸投げもできません。
    • フィールドを全部逐一比較していけばいいんでしょうか? 面倒ですね。

テストというものは複雑なもんとはいえ、もう少しどうにかなって欲しいところです。

そこで、書きました。 its ( https://github.com/youta-t/its ) という、マッチャライブラリです。

できたてホヤホヤ公開して3日目という超新鮮な代物です。
せっかくですんでね、今日は是非コイツをですね、名前だけでもおぼえて帰ってください。気に入っていただけると、もっと嬉しいですね。

インストール

go get github.com/youta-t/its

執筆時点での最新版は v0.1.3 です。 go 1.18 以降 go 1.21以降 が必要[1]です。

its のマッチャ

基本は簡単です。 github.com/youta-t/its パッケージからマッチャが公開されているので、それを使うだけです。

func Add(a, b int) int {
    return a + b
}

func TestAdd(t *testing.T) {
    its.EqEq(10).Match(Add(7, 3)).OrError(t)
}

==(eqeq)で値をマッチする、という例です。期待する値(want)を先、実際の値(got)を Match メソッドにわたすように書くのが its の流儀です。

これで、もしマッチに失敗していれば、エラーメッセージを適当に生成して t.Error を呼び出しておいてくれます[2]。マッチが成功していれば黙っています。

これだけ。ね、簡単でしょ?

このテストをTable Driven Test[3] にしてみましょう。いろんな値の和を求めたいので...

func TestAdd(t *testing.T) {
	for _, testcase := range []struct {
		a, b int
		then its.Matcher[int]
	}{
		{
			a: 1, b: 2,
			then: its.EqEq(3),
		},
		{
			a: 1, b: -3,
			then: its.EqEq(-2),
		},
	} {
		testcase.then.Match(Add(testcase.a, testcase.b)).OrError(t)
	}
}

こう書いていきます。 its.Matcher[T] がマッチャの型です。want を先に書く、という流儀がここで生きてきます。

では試しに、テストを失敗させてみましょう。

func TestAdd(t *testing.T) {
	for _, testcase := range []struct {
		a, b int
		then its.Matcher[int]
	}{
		{
			a: 1, b: 2,
			then: its.EqEq(3),
		},
		{
			a: 1, b: -3,
			then: its.EqEq(-2),
		},
		{
			a: -2, b: 1,  // oops!
			then: its.EqEq(0),
		},
	} {
		testcase.then.Match(Add(testcase.a, testcase.b)).OrError(t)
	}
}

こうしておいてテストを実行すると...

--- FAIL: TestAdd (0.00s)
    file/path/to_test.go:31: 
        ✘ /* got */ -1 == /* want */ 0

と、失敗した部分ではこんなメッセージが書き出されることになっています。

基本となるマッチャは、思いついたところは取り揃えておきました。

  • 単純一致[4]: EqEq, EqEqPtr, Equal, Match
  • 順序関係: GreaterThan, LesserEq, Before, After...
  • 数値関連: NaN, Inf
  • 文字列関連: StringHavingPrefix, StringHavingSuffix, MatchString...
  • []byte: BytesHavingPrefix, BytesHavingSuffix, ...
  • error: Error(errors.Is相当), ErrorAs
  • chan: ClosedChan

などなど。

Matchregexp.RegexpEqualtime.Time を念頭においたものではありますが、いずれもインタフェース[5]にしか依存していないので、応用が利くようになっています。もし自作した型が Equal(T)boolMatch(T)bool メソッドをもっているなら、もちろん使えます!

なお、すべてのマッチャに Example をつけておきました。
https://pkg.go.dev/github.com/youta-t/its を見ると様子がわかると思います。

マッチャを組み合わせて使う

ところで、上のリストには BetweenありませんNotEqualありません ね。どうしたことでしょう? 実はこれ、省略されてるわけではなくて、本当にないんです。

its では、そういったマッチャは すでにあるマッチャを組み合わせて、ユーザが勝手に作れる ようになっています。

たとえば Between 相当のマッチャは、次のように作れます。

itsBetween1And10 := its.All(
    its.GreaterEq(1), its.LesserEq(10)
)

All がマッチャを合成して、「全部マッチしたときに、マッチする」マッチャを作り出しています。
こうなっていれば、Between の端を含むのかどうか? でドキュメントを確認しにゆく必要はありませんね。好きなパターンを作ったらいいのです。

では、All したマッチャが失敗すると、どうなるんでしょう? 見てみましょう。

func TestBetween(t *testing.T) {
	itsBetween1And10 := its.All(
		its.GreaterEq(1), its.LesserEq(10),
	)

	itsBetween1And10.Match(11).OrError(t)  // !!!
}

このテストを実行すると...

--- FAIL: TestBetween (0.00s)
    file/path/to_test.go:40: 
        ✘ // all: (1 ok / 2 matchers)
            ✔ /* want */ 1 <= /* got */ 11
            ✘ /* want */ 10 >= /* got */ 11

こうなります。ちゃんと"サブマッチャ"の結果も示してくれていますね。

論理系のマッチャ合成器は All の他にも、

  • Some: いくつかのマッチャのうち、ひとつでもマッチしたらマッチ
  • None: いくつかのマッチャのうち、ひとつもマッチ しなかったら マッチ
  • Not: マッチャの否定

が提供されています。

slice や map のマッチャ

もちろんあります。これらも「マッチャを合成するマッチャ」として書かれています。

たとえば、Sliceマッチャは、文字通り slice 用のマッチャですが、こういう使い方をします。

its.Slice(
    its.EqEq(1),
    its.EqEq(2),
    its.EqEq(3),
    its.GreaterThan(3)
).Match([]int{1, 2, 3, 7}).OrError(t)

its.Sliceの引数の各マッチャが slice の各要素にマッチすることを検証するようになっています。

map のマッチャも同様の雰囲気になっています。

struct のマッチャ

さて、単純なマッチャはまあ、if 文で頑張って書けないこともないありません。
が、 struct になると話が変わってきます。

フィールドがたくさんあるときにもそれらを一個一個 if していくのは非常に不毛です。

また、各フィールドの == での一致だけを見ればいいなら reflect.DeepEqual で事足りますが、必ずしもそうとは限りません。たとえば...

  • time.Time なフィールドが混ざっている。
  • slice なフィールドがあるが、順序を無視してほしい。
  • 一部フィールドは無視してほしい。

こうしたとき、 reflect.DeepEqual ではうまくいきません[6]

このために its には、任意の struct 用のマッチャを生成するコードジェネレータがあります。
github.com/youta-t/its-structer がそれです。

たとえば、こういうコードがあるとしましょう。

//go:generate go run github.com/youta-t/its/structer -s MyStruct -dest gen
//go:generate gofmt -w ./gen/type.go
package example

import "time"

type MyStruct struct {
	Name      string
	Value     []int
	Timestamp time.Time
}

このファイルを go:generate すると、このファイルの隣に gen パッケージがつくられて、その中に次のものが生成されます。

  • type MyStructSpec
  • func ItsMyStruct(spec MyStructSpec) its.Matcher[example.MyStruct]

型を見ての通り、

gen.ItsMyStruct(gen.MyStructSpec{
    ...
}).Match(example.MyStruct{
    ...
}).OrError(t)

などとして使います。

この MyStructSpec型は、元となった MyStruct 型の各フィールド[7]をマッチャで包んだものになっています。次のような感じです。

type MyStructSpec struct {
	Name      its.Matcher[string]
	Value     its.Matcher[[]int]
	Timestamp its.Matcher[time.Time]
}

ItsMyStruct は、フィールドごとに Spec 側と実際の値をマッチして、全部マッチに成功すれば struct 全体としてもマッチしたものとします。

ですから、使う際には、

timestamp, err := time.Parse(time.RFC3339, "...")
if err != nil {
    t.Fatal(err)
}
gen.ItsMyStruct(gen.MyStructSpec{
    // 最初と最後がアルファベットで、途中は記号も出てきていい
    Name: its.MatchString(regexp.MustCompile("^[a-z]([-a-z0-9.]+[a-z])?$")),

    // 空でないならなんでもいい
    Value: its.SliceUnorderedContaining(
        its.Always[int](),
    ),

    // 時間は時間として一致してほしい
    Timestamp: its.Equal(timestamp),
}).Match( ...

と、 自由 にやったらいいわけですね。

もしマッチに失敗すれば、当然...

--- FAIL: TestMyStruct (0.00s)
    /file/path/to_test.go:47: 
        ✘ type MyStruct:
            ✘ .Name :
                ✘ (/* want */ ^[a-z]([-a-z0-9.]+[a-z])?$).MatchString(/* got */ "github.com/youta-t/its")
            ✔ .Value :
                ✔ []int{ ... (unordered, contain; len: /* got */ 3, /* want */ 1; -0)
                    ✔ (always pass)
                    ~ + /* got */ 11
                    ~ + /* got */ 12
            ✘ .Timestamp :
                ✘ (/* want */ 2024-02-08 01:23:45 +0900 JST).Equal(/* got */ 2024-02-08 08:52:52.560504 +0900 JST m=+0.000783127)

ご覧の通り、各フィールドの成否がわかります。
これまで説明してきてませんでしたが、 から始まる行が「失敗したマッチャ」、は「成功したマッチャ」、~は「失敗したけど、上位マッチャは成功しているマッチャ」 の結果を示しています。

github.com/youta-t/its/structer は、デフォルトではその //go:generate が書かれたファイルにある struct を対象とします。
ところで、明示的に指定すれば他のパッケージの struct についてもコードを生成することもできます。

//go:generate go run github.com/youta-t/its/structer -source PACKAGE_NAME -as-package -s STRUCT_NAME -d gen

のように -source-as-package を渡してやると、 -source の値をパッケージ名だと解釈し、その上で -s で指定した[8] struct を対象にコードを生成します[9]

外部ライブラリの型についてテストしなければならないケースもバッチリです。

おわりに

...と、こんなライブラリを作ってみました。
まだドキュメントが貧弱だったり、もっと便利なマッチャを増やしたかったりしていますが、とりあえず動くものができたので公開した次第です。

どうでしょう、お気に召しましたでしょうか?
ご意見、ご issue、ご pull request などをいただけたら幸いです!

脚注
  1. 2/14更新。手元の確認環境が参照しているgoが誤っていて、対応バージョンを勘違いしていました。ごめんなさい。 ↩︎

  2. 代わりに t.Fatal を呼び出す .OrFatal メソッドもあります。 ↩︎

  3. https://go.dev/wiki/TableDrivenTests ↩︎

  4. "単純"一致だけでさえ 4 通りも出てきているのが、go のつらいところですね。単純ってなんなんでしょうね? ↩︎

  5. interface { Match(T)bool }interface { Equal(T)bool }↩︎

  6. 更にいうと非公開のフィールドも比較してしまうわけですが、それが妥当なのかどうかも場合によりけりでしょう。 ↩︎

  7. 公開されているものに限る。 ↩︎

  8. 省略すると、すべての struct を対象にします。 ↩︎

  9. このときも、 struct を見つけたファイルと同じ名前のファイルを生成します。 ↩︎

Discussion