Go2 Advent Calendar 2017 20日目 の転載です。
Goでテストを書くお話です。
基本的なところから、応用的なテストの書き方(パターン?)をまとめておくことにしました。
ポイントを先に列挙します:
- テストのエラーメッセージは丁寧に書こう
- テーブルテストを活用してパターンを整理しながら網羅しよう
t.Run
をつかって大きなテストを分割しようt.Helper
をつかってテストエラーの箇所をわかりやすくしよう- テスト用のデータは
testdata
ディレクトリに置こう - Setup/Teardownをうまく書いてテストの見通しをよくしよう
- 等
では、見ていきましょう。
実装
tenntennさんの もっと楽して式の評価器を作る を参考に、シンプルな計算機能を持つ関数(Compute
)を書いて、それをテストしてみます(みんなはテストから書こう)。
実装コード:
package calc
import (
"go/token"
"go/types"
)
func Compute(expr string) (string, error) {
tv, err := types.Eval(token.NewFileSet(), types.NewPackage("main", "main"), token.NoPos, expr)
if err != nil {
return "", err
}
return tv.Value.String(), nil
}
テストを書いてみよう
シンプルなテスト
まずはシンプルに。
func TestCompute(t *testing.T) {
s, err := Compute("1+1")
if err != nil {
t.Fatal(err)
}
if s != "2" {
t.Errorf("Compute(1+1) = %s, want 2", s)
}
}
1+1=2
、簡単ですね。
テスト結果をassertするようなテストユーティリティは、エラーメッセージを適切に書く習慣がつかないので、個人的にはあまりおすすめしません。このあたりは以下のFAQを読むと良いと思います。
どうしても使いたい場合は
が良いと思います。
テーブルを使ったテスト
func TestCompute(t *testing.T) {
computeTests := []struct {
in string
out string
}{
{"1+1", "2"},
{"1.0/2.0", "0.5"},
}
for _, test := range computeTests {
s, err := Compute(test.in)
if err != nil {
t.Fatal(err)
}
if s != test.out {
t.Errorf("Compute(%s) = %s, want %s", test.in, s, test.out)
}
}
}
パターンを網羅したい場合にとても便利です。ここもテストのエラーメッセージをわかりやすく記述しておきましょう。
参考:
QuickCheck(testing/quick)でブラックボックステストをする
testing/quickを使って、ブラックボックス的なテストを書きます。quick.Check
に渡した関数の引数に乱数が入るので、それを使ってテストをします。
func TestCompute(t *testing.T) {
add := func(a, b int16) bool {
s, err := Compute(fmt.Sprintf("%d+%d", a, b))
if err != nil {
t.Fatal(err)
}
expected := strconv.Itoa(int(a) + int(b))
if s != expected {
t.Logf("Compute(%d+%d) = %s, want %s", a, b, s, expected)
return false
}
return true
}
if err := quick.Check(add, nil); err != nil {
t.Fatal(err)
}
}
使い所が難しいですが、乱数を与えてテストすることでバグを見つけられることがあります。
参考:
t.Runを使ってテストを分割する(サブテスト)
t.Runをテストを分けるために使うと効果的です。
func TestCompute(t *testing.T) {
t.Run("add sub", func(t *testing.T) {
testCompute(t, "1+1", "2")
testCompute(t, "-2+1", "-1")
})
t.Run("div", func(t *testing.T) {
testCompute(t, "1.0/2.0", "0.5")
testCompute(t, "2.0/1.0", "2")
})
}
func testCompute(t *testing.T, in, expected string) {
s, err := Compute(in)
if err != nil {
t.Fatal(err)
}
if s != expected {
t.Errorf("Compute(%s) = %s, want %s", in, s, expected)
}
}
ここでは、足し算引き算と割り算、のテストをまとめてみました。また、テストを別の関数(testCompute
)に記述し、テストの内容の見通しを良くしました。
参考:
t.Helperを使う
Go1.9からt.Hepler()
が追加されましたので見てみましょう。
先程の testCompute
をもう一度見てみます。この中でテストが失敗した場合、go test
のエラーメッセージは testCompute
内で発生したことを行番号で教えてくれます。ですが、どのテストで失敗したのかはややわかりにくいです。
func testCompute(t *testing.T, in, expected string) {
s, err := Compute(in)
if err != nil {
t.Fatal(err)
}
if s != expected {
t.Errorf("Compute(%s) = %s, want %s", in, s, expected) // <- ここでテストが失敗した場合、はここの行番号が表示される
}
}
ここで、testCompute
にt.Helper()
の1行を追加します。すると、testCompute
内で発生したエラーは呼び元のTestCompute
のどの行で失敗したのかを表示するようになります。
func TestCompute(t *testing.T) {
t.Run("add sub", func(t *testing.T) {
testCompute(t, "1+1", "2")
testCompute(t, "-2+1", "-1")
testCompute(t, "1+1", "二") // エラーメッセージでここの行番号が失敗したことが表示される!!
})
}
func testCompute(t *testing.T, in, expected string) {
t.Helper() // <- これを追加する
s, err := Compute(in)
if err != nil {
t.Fatal(err)
}
if s != expected {
t.Errorf("Compute(%s) = %s, want %s", in, s, expected) // <- ここでテストが失敗した場合、呼び元で失敗したことがレポートできるようになる
}
}
t.Helper()
はテストのサポート関数を記述した場合は必ず付けると良いと思います。
参考:
テストでSetup/Teardownを使う
テストの内容をテキストファイルに置き、それを読んでテストする、という内容に無理矢理書き換えてみます。
func TestCompute(t *testing.T) {
f, err := os.Open("testdata/compute.txt")
if err != nil {
t.Fatal(err)
}
defer f.Close()
r := bufio.NewReader(f)
for {
line, _, err := r.ReadLine()
if err == io.EOF {
break
}
test := strings.Split(string(line), "=")
if len(test) != 2 {
t.Fatal("invalid test data: %s", string(line))
}
testCompute(t, test[0], test[1])
}
}
このファイルの読み込み処理をSetup/Teardownにまとめてみると↓
func SetupComputeTest(t *testing.T, fname string) (*bufio.Reader, func()) {
f, err := os.Open(fname)
if err != nil {
t.Fatal(err)
}
return bufio.NewReader(f), func() {
f.Close()
}
}
func TestCompute(t *testing.T) {
r, Teardown := SetupComputeTest(t, "testdata/compute.txt")
defer Teardown()
for {
line, _, err := r.ReadLine()
if err == io.EOF {
break
}
test := strings.Split(string(line), "=")
if len(test) != 2 {
t.Fatal("invalid test data: %s", string(line))
}
testCompute(t, test[0], test[1])
}
}
テスト対象が複雑なものではないので無理矢理感がありますが、事前準備とテスト後の処理をまとめておけるので便利です。httptestやモックの取りまとめや、データベースなどの処理などを書いておくと良さそうです。
なお、テスト用のデータはtestdata
ディレクトリに置くと良いです。Goはこのtestdata
をパッケージとしては見なさないため、様々なテストデータを置くことができます。 https://pkg.go.dev/cmd/go#hdr-Test_packages
テストでSetup/Teardownを使う(structにtestingを組み込む)
type computeTest struct {
testing.TB
f *os.File
r *bufio.Reader
}
func SetupComputeTest(tb testing.TB, fname string) *computeTest {
f, err := os.Open(fname)
if err != nil {
tb.Fatal(err)
}
return &computeTest{
TB: tb,
f: f,
r: bufio.NewReader(f),
}
}
func (t *computeTest) Teardown() {
t.f.Close()
}
func (t *computeTest) testData() (in, out string, ok bool) {
line, _, err := t.r.ReadLine()
if err == io.EOF {
return "", "", false
}
test := strings.Split(string(line), "=")
if len(test) != 2 {
t.Fatal("invalid test data: %s", string(line))
}
return test[0], test[1], true
}
func TestCompute(tt *testing.T) {
t := SetupComputeTest(tt, "testdata/compute.txt")
defer t.Teardown()
for {
in, out, ok := t.testData()
if !ok {
break
}
testCompute(tt, in, out)
}
}
この例も無理矢理感ありますが、例として。 ポイントは、構造体に testingのオブジェクトを持つようにしているところです。こうすることで、各メソッド内で発生したエラー処理を隠蔽できます。適切に隠蔽しておくことで、テストの内容の見通しが良くなります。(この例の場合はイマイチ)。 このパターンも、データベースや関連するモックなどを始め、テスト中によく使うチェックのための処理をまとめて実装しておくと良いです。
パッケージ全体でSetup/Teardownを使う
おまけですが。
この場合は func TestMain(m *testing.M)
が役に立ちます。
func TestMain(m *testing.M) {
setup()
ret := m.Run()
teardown()
os.Exit(ret)
}
m.Run
でパッケージ内のテストがすべて実行されるため、パッケージ全体のSetupとTeardownが書けます。
参考:
最後に
Goを開発で使ってきた中で使ってきたテストのパターンを簡単にまとめました。どの例もやりすぎないことがポイントです。過度な抽象化やサポート関数は読み手に混乱を与えるかもしれないからです。
と、偉そうに書いてますが、いい塩梅にバランスをとるのは正直難しいです。悩ましい…。
gistに置いておきました https://gist.github.com/atotto/d753d91f5f3661b07a3391c0c9f6fb05