見出し画像

Go: テーブル駆動テストをデバッグしやすくしてみる


こんにちは!

株式会社オープンストリーム・技術創発推進室所属の髙岡です。
最近は vscode で golang 書いてます。皆さんも、go 書いてますか? テストはどうでしょう? 書いてる? いいですね! さて、では皆さん……

テーブル駆動テストのデバッグ、どうしてます?

go でユニットテストを書く時は、標準ライブラリの testing ( https://pkg.go.dev/testing ) を使うことになるでしょう。
条件を少しづつ変えながらテストケースを挙げてゆく時、次のようなパターンがよく使われていると思います。

package example_test

import (
	"testing"

	"opst.co.jp/example"
)

func TestFoo(t *testing.T) {
	for name, testcase := range map[string]struct {
		inputA      int
		inputB      int
		expectation int
	}{
		"1 + 1 = 2": {
			inputA: 1, inputB: 1, expectation: 2,
		},
		"1 + 2 = 3": {
			inputA: 1, inputB: 2, expectation: 3,
		},
		// ... and more ...
	} {
		t.Run(name, func(t *testing.T) {
			if example.ComplexFunction(testcase.inputA, testcase.inputB) != testcase.expectation {
				t.Error("fail!")
			}
		})
	}
}

テストの入力と結果の組(テストケース)を map や slice に詰めておいて、これをイテレートしながら順次テストを実施していく、というスタイルですね。
テーブル駆動テスト、なんて呼ばれています。

インターネットを検索してもこれを勧める記事は簡単にみつかります。また、入力・出力関係に集中する部分とテスト手順に集中する部分とが分かれていて、コードの取り回しも悪くないように思えます。実際、これまで私もよく書いてきました。

では、これが完璧か? というと、どうもそうでもないような気がするんです。たとえば、次のような場合を考えてみましょう。

package example_test

import (
	"testing"

	"opst.co.jp/example"
)

func TestFoo(t *testing.T) {
	for name, testcase := range map[string]struct {
		inputA      int
		inputB      int
		expectation int
	}{
		"1 + 1 = 2": {
			inputA: 1, inputB: 1, expectation: 2,
		},
		"1 + 2 = 3": {
			inputA: 1, inputB: 2, expectation: 3,
		},
		// ...snip...
		"9 + 10 = 19": {
			inputA: 9, inputB: 10, expectation: 119, // wrong test case!
		},
		"10 + 10 = 20": {
			inputA: 10, inputB: 10, expectation: 20,
		},
	} {
		t.Run(name, func(t *testing.T) {
			if example.ComplexFunction(testcase.inputA, testcase.inputB) != testcase.expectation {
				t.Error("fail!")
			}
		})
	}
}

テストケースが、実は 100 通りありました! で、このうち特定のひとつだけが fail している、とします。

こういうときには、いろんなことが気になります。マズいのはテストケースの方なのか(この例だと、どうやらそうですね)、それとも実装のほうなのか? そもそもどういう動作してるんだ?などなど。
こうなると、ブレークポイントを設置してデバッグして、その特定のテストケースをインスペクションしたくなってきます。

ときたら、エディタのデバッグ機能をつかって、特定のテストケースを "debug test" すれば……

vscode のデバッグ機能は table driven test のテストケースを個別に実行できない

……できません。少なくとも vscode では、全部一気にテストすることしかできないのです。特定のテストケースだけテストを実行することは、できません。

「できるようにしてくれ!」という要望は vscode-go のリポジトリに挙がっていますが( https://github.com/golang/vscode-go/issues/2445 , https://github.com/golang/vscode-go/issues/1602 )、実現はどうも困難であるようです。

こういうときは普通どうする

まあ、手はあります。"条件付きブレークポイント"ってやつですね。

条件付きブレークポイント

こうしてやれば、特定のケースだけを対象にブレークすることはできます。……できはするのですが、ちょいと面倒です。

さらに、エディタの "debug test" から実行するときは、対象範囲のすべてのテストが実行されてしまいます。今回の例だと、100通りあるうちの 99 通りのテストケースには関心がないのですが、それらが実行されている間も待つしかありません。テスト対象関数が遅いものだと、さらに待たされることになります。

この「全部実行される」という挙動は、テスト対象関数にブレークポイントを設置することも妨げます。テスト対象関数自身は「今どういうテストケースを実行しているのか」ということは知らないので、適当にブレークポイントを設置すると、関心がないテストケースでもブレークしてしまうことになります。

実に、不便です。

テーブル駆動テストのデバッグ、どうにかならんか?

どうにかならんもんでしょうか。

特定のテストケースだけを実行したいのです。もしそうできれば、余計な待ちも発生しません。エディタの端をちょっとクリックするだけでブレークポイントが設定できて、面倒な条件をセットする必要もありません。確実簡単に、テストしたい条件についてブレークできます。

そこで、こういうことを考えてみました。

こんなんどうでしょう

テストコードを、次のように書き換えてみます。

func TestFooTheory(t *testing.T) {

	type Testcase struct {
		inputA      int
		inputB      int
		expectation int
	}

	theory := func(testcase Testcase) func(t *testing.T) {
		return func(t *testing.T) {
			if example.ComplexFunction(testcase.inputA, testcase.inputB) != testcase.expectation {
				t.Error("fail!")
			}
		}
	}

	t.Run("1 + 1 = 2", theory(Testcase{
		inputA: 1, inputB: 1, expectation: 2,
	}))
	t.Run("1 + 2 = 3", theory(Testcase{
		inputA: 1, inputB: 2, expectation: 3,
	}))
	// ... snip ...
	t.Run("9 + 10 = 19", theory(Testcase{
		inputA: 9, inputB: 10, expectation: 119, // wrong test case!
	}))
	t.Run("10 + 10 = 20", theory(Testcase{
		inputA: 10, inputB: 10, expectation: 20,
	}))
}

t.Run にわたす関数を生成する関数 theory を先に定義しておいて、そのあと各テストケースについて t.Run をしています。なお、関数名 theory は、JUnit4 の @Theories ( https://github.com/junit-team/junit4/wiki/Theories ) を念頭においてつけました。

こうなっていると、vscode も個々のテストケースを認識してくれます。

テストケースごとに debug test できる!

しかも、テスト手順(theory)とテストケース(t.Run の群れ)とがコード上で混ざり合わないように書けていますから、テーブル駆動テストと似たようなことは十分に実現できています。

これならデバッグもやりやすくなるんではなかろうか? というわけですね。

コードの流れとしても、上から「Testcase の型宣言」と「その型をどう使うつもりか?」が先にきて、個別のテストケースはその後に置けるようになっています。私としては、こっちのほうが好みにあいます。

試しにこれをつかってみたところ、テストケースをざっと書き下せる範囲であれば、十分良いように思えます。デバッグは非常にスムーズです。「条件付きブレークポイントつくるのか……」という躊躇がなくなったので、開発体験としては実に良いです。

ただし、これも万能ではありません。特に、テストケースを複数の条件の組み合わせから(多重ループとかをつかって)機械的に生成したい場合とは相性がよくありません。t.Run の呼び出しをループの外に置けないので、そうした場合では結局 vscode の機能で debug test できないのです。
(テストコード生成をやればこのハードルは突破できますが、そこまでやるほどのことか? と感じていて、まだ手を出さないでいます)

まとめ

……というわけで、golang 開発でちょっと工夫したよ!というお話でした。

golang は(良くも悪くも)工夫しがいがある言語ですよね。うまい工夫を思いつくと嬉しい気分になるものです。これからも、どんどん変なことを思いついていければいいな、と思います。


株式会社オープンストリームのご案内

コーポレートサイト
https://www.opst.co.jp/

サービス、ソリューションに関するお問い合わせ
https://www.opst.co.jp/contact/c_service/

採用サイト
https://recruit.opst.co.jp/

この記事が気に入ったらサポートをしてみませんか?