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 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 などをいただけたら幸いです!
-
2/14更新。手元の確認環境が参照しているgoが誤っていて、対応バージョンを勘違いしていました。ごめんなさい。 ↩︎
-
代わりに
t.Fatal
を呼び出す.OrFatal
メソッドもあります。 ↩︎ -
"単純"一致だけでさえ 4 通りも出てきているのが、go のつらいところですね。単純ってなんなんでしょうね? ↩︎
-
interface { Match(T)bool }
やinterface { Equal(T)bool }
。 ↩︎ -
更にいうと非公開のフィールドも比較してしまうわけですが、それが妥当なのかどうかも場合によりけりでしょう。 ↩︎
-
公開されているものに限る。 ↩︎
-
省略すると、すべての
struct
を対象にします。 ↩︎ -
このときも、
struct
を見つけたファイルと同じ名前のファイルを生成します。 ↩︎
Discussion