artarch
Works Posts Make Category Tags About

Goでテストを書く(テストの実装パターン集)

Published Dec 20, 2017 by Ato Araki in Go at https://blog.artarch.net/articles/golang/go-test-pattern/

Table of Contents

  • 実装
  • テストを書いてみよう
    • シンプルなテスト
    • テーブルを使ったテスト
    • QuickCheck(testing/quick)でブラックボックステストをする
    • t.Runを使ってテストを分割する(サブテスト)
    • t.Helperを使う
    • テストでSetup/Teardownを使う
    • テストでSetup/Teardownを使う(structにtestingを組み込む)
    • パッケージ全体でSetup/Teardownを使う
  • 最後に
  • 参考

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を読むと良いと思います。

  • https://go.dev/doc/faq#assertions
  • https://go.dev/doc/faq#testing_framework

どうしても使いたい場合は

  • https://pkg.go.dev/github.com/stretchr/testify/assert

が良いと思います。

テーブルを使ったテスト

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)
		}
	}
}

パターンを網羅したい場合にとても便利です。ここもテストのエラーメッセージをわかりやすく記述しておきましょう。

参考:

  • https://github.com/golang/go/wiki/TableDrivenTests
  • https://go.dev/doc/code#Testing

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)
	}
}

使い所が難しいですが、乱数を与えてテストすることでバグを見つけられることがあります。

参考:

  • https://pkg.go.dev/testing/quick
  • http://ymotongpoo.hatenablog.com/entry/2012/11/30/155846

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)に記述し、テストの内容の見通しを良くしました。

参考:

  • https://pkg.go.dev/testing#hdr-Subtests_and_Sub_benchmarks
  • https://pkg.go.dev/testing#T.Run

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() はテストのサポート関数を記述した場合は必ず付けると良いと思います。

参考:

  • https://pkg.go.dev/testing#T.Helper
  • https://go.dev/doc/go1.9#test-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が書けます。

参考:

  • https://pkg.go.dev/testing#hdr-Main
  • http://atotto.hatenadiary.jp/entry/2014/12/19/214357

最後に

Goを開発で使ってきた中で使ってきたテストのパターンを簡単にまとめました。どの例もやりすぎないことがポイントです。過度な抽象化やサポート関数は読み手に混乱を与えるかもしれないからです。

と、偉そうに書いてますが、いい塩梅にバランスをとるのは正直難しいです。悩ましい…。

gistに置いておきました https://gist.github.com/atotto/d753d91f5f3661b07a3391c0c9f6fb05

参考

  • https://github.com/golang/go/wiki/LearnTesting

See Also

  • Go Puzzlers
  • "プログラミング言語Goフレーズブック"を戴きました

LastModified: 2022-02-04T10:30:00Z

© 2023 Ato Araki