はじめに
筆者はGoだけではなく、Scalaなど他言語を扱った経験もあり、しばしばGoには他の言語にあるXという機能がなぜないのだろう?
たとえば、テスト関数ごとに暗黙的に呼ばれる初期化関数の定義があります。データベースに対するDrop・
しかし、Goにはこのようなテスト関数ごとに暗黙的に実行される初期化処理を定義できません。Goが他言語に劣っているようにもみえますが、本当にそうなのでしょうか? 公式のFAQにもあるように、とある機能がないことには何かしらの理由があるようです。
本記事では、テスト関数ごとの初期化処理を題材にし、Goコミュニティでどのような議論がされていったのか、提案されたプロポーザルについて触れながらどう実現すると良いのかを考察します。
明示的に初期化処理を行う
早速、テスト関数ごとの初期化処理を考えてみましょう。シンプルな実装を考えると、次のようになります。testingパッケージを用いて、初期化処理関数であるSetupを都度呼び出しています。
package main
import (
"fmt"
"testing"
)
func setup(t *testing.T) {
t.Helper()
// 初期化処理
fmt.Println("setup")
}
func Test1(t *testing.T) {
setup(t)
// テスト実施
fmt.Println("do test1")
}
func Test2(t *testing.T) {
setup(t)
// テスト実施
fmt.Println("do test2")
}
実行結果は次のようになります。
$ go test setup do test1 setup do test2 PASS ok sample 0.111s
初期化処理であるsetup関数が想定通り2回呼ばれています。しかし、テスト関数ごとに明示的にSetup関数を呼ぶのは手間です。暗黙的に実行されないかと考えたいところです。
なお、setup関数で(*testing.
メソッドを呼んでいる理由は、ヘルパー関数であることがマークでき、テスト失敗時に失敗箇所や原因などが追いやすくなるからです。
パッケージの最初に初期化処理を入れる
そこで、テスト関数ごとではなく、テスト対象のパッケージごとに初期化処理を入れてみることにします。その場合は、次のようにTestMain関数を用います。
package main
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// 初期化処理
println("setup")
code := m.Run()
os.Exit(code)
}
func Test1(t *testing.T) {
// テスト実施
fmt.Println("do test1")
}
func Test2(t *testing.T) {
// テスト実施
fmt.Println("do test2")
}
実行結果は次のようになります。
$ go test setup do test1 do test2 PASS ok sample 0.297s
TestMain関数は、パッケージのテストごとに1度だけ呼ばれます。そのため、Setup関数も1度だけ呼ばれています。
このように、TestMain関数を用いるとパッケージごとの暗黙的な初期化処理を実行できます。それでは、関数ごとに暗黙的な初期化処理を実行するにはどうすれば良いのでしょうか。
testingパッケージのみを使用して、テスト関数ごとに暗黙的に処理を実行する方法はありません。ただし、そのような機能の要望はあるようです。すでに不採択にはなっていますが、過去には
これらのプロポーザルの共通点は、他の多くの言語やフレームワークにあるようにテスト関数ごとの初期化処理の仕組みを導入するべきだという主張です。
しかし、Goのメンテナの意見は次のように一貫しています。
簡単に意訳すると、
たしかにGoogleのGo Style Guideにあるように明確さの観点で考えるとその通りです。
しかし、テストケースが数百と増えていった場合に、初期化関数を呼び忘れる可能性があるなど、導入提案側からは現実的にメンテナンスが難しくなるとの声が挙がっていました。また、これらのプロポーザルでは、暗黙的に初期化を行う方法としていくつかのアイデアが投稿されていました。
そこで、プロポーザルで投稿されていたアイデアを紹介しながら、より良いテスト関数ごとの初期化処理について考察します。
サブテストを用いる方法
まず、#27927に付いていたコメントを参考に、サブテストを用いる方法を紹介します。
package main
import (
"strconv"
"testing"
)
func setup(t *testing.T) {
t.Helper()
// 初期化処理
println("setup")
}
func TestEverything(t *testing.T) {
tests := []func(t *testing.T){
func(t *testing.T) {
// テスト実施
println("do test1")
},
func(t *testing.T) {
// テスト実施
println("do test2")
},
}
for i, fn := range tests {
setup(t)
t.Run(strconv.Itoa(i), fn)
}
}
この方法では、サブテスト関数の前にsetup関数を呼ぶことはできていますが、大きな欠点として各サブテストに意味のあるテスト名が付けられていません。テストが落ちた時にどのサブテストで落ちたのか見つけることが困難になります。
次に、#27927における別のコメントを参考に、サブテストに意味のある名前をつける方法を紹介します。
package main
import (
"testing"
)
func setup(t *testing.T) {
t.Helper()
// 初期化処理
println("setup")
}
func Test(t *testing.T) {
fs := map[string]func(*testing.T){
"test1": test1,
"test2": test2,
}
for name, f := range fs {
setup(t)
t.Run(name, f)
}
}
func test1(t *testing.T) {
// テスト実施
println("do test1")
}
func test2(t *testing.T) {
// テスト実施
println("do test2")
}
この方法ではサブテストの名前をキーとし、テスト関数を値にしたマップを作成することで、サブテストに名前を付けています。この欠点は、手動で全テストをmapに詰めているため、テスト数が増えてくるとテストの抜け漏れや名前の付け間違いなど、やはりメンテが難しくなっていきます。
リフレクションを用いる方法
#27927のさらに別のコメントでは、リフレクションを用いた方法も紹介されていました。grpc-goライブラリのリポジトリで実際に導入された方法のようです。
package main
import (
"reflect"
"strings"
"testing"
)
func Test(t *testing.T) {
runSubTestsWithSetup(t, s{})
}
func runSubTestsWithSetup(t *testing.T, x interface{}) {
t.Helper()
tests, setup := testFuncs(t, x, "Test", "Setup")
for name, fnc := range tests {
t.Run(name, func(t *testing.T) {
setup(t)
fnc(t)
})
}
}
func testFuncs(t *testing.T, x interface{}, prefix, setupName string) (map[string]func(*testing.T), func(*testing.T)) {
t.Helper()
typ := reflect.TypeOf(x)
v := reflect.ValueOf(x)
tests := make(map[string]func(*testing.T))
var setup func(t *testing.T)
for i := 0; i < typ.NumMethod(); i++ {
method := typ.Method(i).Name
switch {
case method == setupName:
setup = testFuncByName(t, v, method)
case strings.HasPrefix(method, prefix):
name := strings.TrimPrefix(method, prefix)
tests[name] = testFuncByName(t, v, method)
}
}
if setup == nil {
t.Fatalf("type %v does not have method %v", v.Type(), setupName)
}
return tests, setup
}
func testFuncByName(t *testing.T, v reflect.Value, name string) func(*testing.T) {
m := v.MethodByName(name)
if !m.IsValid() {
t.Fatalf("type %v does not have method %v", v.Type(), name)
}
f, _ := m.Interface().(func(*testing.T))
if f == nil {
t.Fatalf("method %v has unexpected signature (%T)", name, m.Interface())
}
return f
}
type s struct{}
// リフレクションを用いるため、以下関数は頭文字を大文字に
func (s) Setup(t *testing.T) {
t.Helper()
// 初期化処理
println("setup")
}
func (s) Test1(t *testing.T) {
// テスト実施
println("do test1")
}
func (s) Test2(t *testing.T) {
// テスト実施
println("do test2")
}
この方法では、共通の初期化処理を実行したいテスト関数群と初期化関数を1つの型のメソッドとしておきます。そして、リフレクションで動的に初期化関数とテスト関数を取得し、メソッド名をテスト名としてテストを実行しています。この場合、テスト関数に余分なインデントが付くことがなく、標準の書き方とほぼ同じように書けます。また、テスト関数の追加や初期化処理ごとにテスト関数をまとめることも容易になります。実際にgrpc-goでは既存の大量のテスト関数をこの方法で置き換えることに成功したようです。
まとめ
本記事では、Goには存在しないテスト関数ごとの初期化処理に着目しました。ここで紹介した手法は後処理にも適用できるでしょう。大規模開発で大量のテスト関数を保守していく際に、共通処理を少し工夫することでメンテナンス性を上げられそうです。しかし、テスト関数がそこまで多くならないようであれば必要な際に都度呼び出すのも明示的で良いでしょう。
プロポーザルを追ってみると、他言語で一般的だからといっても安易に取り入れることはせず、Goのメンテナが一貫して明示的さやシンプルさをとても大事にしていることが分かります。どの言語においても、その言語が生まれた背景や文化があるため、その文化を学ぶことも大事だと改めて感じました。特にGoにおいては、やはり
読者のみなさんも気になる言語仕様を見かけた場合には、どんな背景で決まったのか、改善案