つきなみGo

GoにはなぜXという機能がないのか? テスト関数ごとの暗黙的な初期化処理の実現を考察する

はじめに

筆者はGoだけではなく、Scalaなど他言語を扱った経験もあり、しばしばGoには他の言語にあるXという機能がなぜないのだろう?と考えることがあります。

たとえば、テスト関数ごとに暗黙的に呼ばれる初期化関数の定義があります。データベースに対するDrop・Create・Insertなどの処理をテストごとに実行したい場合に定義できると便利でしょう。同じ処理を都度書いているとテストケースが増えてきた時にメンテナンス性が下がってしまったり、初期化処理を追加し忘れてしまう恐れなどもあります。

しかし、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.T).Helperメソッドを呼んでいる理由は、ヘルパー関数であることがマークでき、テスト失敗時に失敗箇所や原因などが追いやすくなるからです。

パッケージの最初に初期化処理を入れる

そこで、テスト関数ごとではなく、テスト対象のパッケージごとに初期化処理を入れてみることにします。その場合は、次のように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パッケージのみを使用して、テスト関数ごとに暗黙的に処理を実行する方法はありません。ただし、そのような機能の要望はあるようです。すでに不採択にはなっていますが、過去には「proposal: testing: per-test setup and teardown support」#27927「proposal: testing: middleware for testing.TB (?)」#40984といったプロポーザルが提案されていました。

これらのプロポーザルの共通点は、他の多くの言語やフレームワークにあるようにテスト関数ごとの初期化処理の仕組みを導入するべきだという主張です。

しかし、Goのメンテナの意見は次のように一貫しています。

Various of us who have had to write code like this have found it helpful to write it explicitly instead of having an implicit thing that applies to every test in the package. Sometimes only 9 of 10 tests need a particular setup/teardown. Another common pattern is to put setup/teardown in one Test and then put the tests that need that setup/teardown into subtests. Or write a helper that does the setup/teardown and pass in the 'body' to run in between. There are lots of ways. We shouldn't hard-code one.

https://github.com/golang/go/issues/27927#issuecomment-446711881

On the other hand, implicit test setup and teardown adds actions that are not clearly visible when looking at a large testsuite. And a single package will often have different kinds of tests, some of which need a particular kind of setup and teardown and some of which need a different kind, so over time the complexity of the setup and teardown tend to increase.

I don't think there is an obvious win here so we are choosing to be explicit and simple, as is the common pattern for the Go language.

https://github.com/golang/go/issues/27927#issuecomment-446801602

簡単に意訳すると、⁠10個のテストのうち9個のみが初期化処理を必要とすることもあるため、暗黙的ではなく明示的に書くべき」⁠暗黙的な実行は時間の経過とともに複雑さを増すことになるため明示的でシンプルであるべき」と主張しています。

たしかに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においては、​​やはり「Goに入ってはGoに従え」の精神が重要そうです。

読者のみなさんも気になる言語仕様を見かけた場合には、どんな背景で決まったのか、改善案(プロポーザル)は出されているのか、などを調べてみると良いかもしれません。

おすすめ記事

記事・ニュース一覧