Go言語でテストマッチャライブラリ its を書いた
はじめまして。 youta-t です。
Java とか Python とか触ってきましたが、ここ1年くらいは Go で開発してます。よろしくおねがいします。
さて、ソフトウェア開発につきものなのがテストです。
Go のテストは... ただテストである以上に簡単じゃない、と思うんですよね。
- エラーメッセージは全部手作りすることになる:
- いちいちメッセージ考えて
t.Errorfするのは、地味に骨が折れます。
- いちいちメッセージ考えて
- データとアルゴリズムが離れすぎる:
- Table Driven Test もいいんですが、Table が大きく育つとデータと処理の間の見通しが悪くなりがちです。
- 様々な条件分岐を Table Driven Test に埋め込もうと思うと、Table の側もフラグだらけになるし、テストの側も分岐だらけになって、テストそのものがバグります。
-
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
などなど。
Match は regexp.Regexp 、Equal は time.Time を念頭においたものではありますが、いずれもインタフェース[5]にしか依存していないので、応用が利くようになっています。もし自作した型が Equal(T)bool や Match(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 MyStructSpecfunc 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 などをいただけたら幸いです!
-
2/14更新。手元の確認環境が参照しているgoが誤っていて、対応バージョンを勘違いしていました。ごめんなさい。 ↩︎
-
代わりに
t.Fatalを呼び出す.OrFatalメソッドもあります。 ↩︎ -
"単純"一致だけでさえ 4 通りも出てきているのが、go のつらいところですね。単純ってなんなんでしょうね? ↩︎
-
interface { Match(T)bool }やinterface { Equal(T)bool }。 ↩︎ -
更にいうと非公開のフィールドも比較してしまうわけですが、それが妥当なのかどうかも場合によりけりでしょう。 ↩︎
-
公開されているものに限る。 ↩︎
-
省略すると、すべての
structを対象にします。 ↩︎ -
このときも、
structを見つけたファイルと同じ名前のファイルを生成します。 ↩︎
Discussion