Go のテストコードに入門する

Go でテストコードを書くにあたり、標準パッケージである testing パッケージの使い方を調べました。

testing

環境

  • go 1.19.2

コード

ファイル構造

.
├── go.mod
├── go.sum
└── pkg
    └── num
        ├── num.go
        └── num_test.go

go module 作成

$ go mod init sample.com/sample/sample

./pkg/num/num.go

package num

import (
    "golang.org/x/xerrors"
)

type Num struct {
    value int
}

func NewNum(value int) *Num {
    return &Num{
        value: value,
    }
}

func (n Num) Double() (int, error) {
    if n.value == 0 {
        return 0, xerrors.New("not accepted")
    }
    return n.value * 2, nil
}

./pkg/num/num_test.go

package num_test

import (
    "fmt"
    "testing"

    "sample.com/sample/sample/pkg/num"
)

// helper function として利用する関数
func assertEqual(t *testing.T, got, want int) {
    // helper function である関数内で呼ぶと、helper funnction として適当な処理を付与してくれるメソッド
    // https://pkg.go.dev/testing#T.Helper
    t.Helper()
    if got != want {
        t.Errorf("expected %d, but %d", want, got)
    }
}

// シンプルなテストコード 正常性テスト
func TestDouble1(t *testing.T) {
    in, want := 1, 2
    x := num.NewNum(in)

    got, _ := x.Double()
    // helper function を利用
    assertEqual(t, got, want)

    // 以下と同じ意
    /*
       got, _ := x.Double()
       if got, _ := x.Double(); got != want {
           t.Errorf("expected %d, but %d", want, got)
       }
   */
}

// シンプルなテストコード 異常性テスト
func TestDouble2(t *testing.T) {
    in := 0
    x := num.NewNum(in)

    if _, err := x.Double(); err == nil {
        t.Errorf("expected error")
    }
}

// Table Driven Test
func TestDouble3(t *testing.T) {
    // テストケースとしたい定義をスライス内に宣言
    cases := []struct {
        name    string
        in      int
        want    int
        wantErr bool
    }{
        {
            name:    "normal",
            in:      1,
            want:    2,
            wantErr: false,
        },
        {
            name:    "abnormal",
            in:      0,
            want:    0,
            wantErr: true,
        },
    }

    for _, tt := range cases {
        // イテレートされる各処理のスコープ内で、新たに変数を初期化してあげる
        tt := tt
        // 第一引数の名前でサブテストを実行してくれるメソッド
        // https://pkg.go.dev/testing#T.Run
        t.Run(tt.name, func(t *testing.T) {
            // *testing.T.Parallel を実行しておくと、サブテストを並行実行してくれる
            // https://pkg.go.dev/testing#T.Parallel
            t.Parallel()
            x := num.NewNum(tt.in)
            got, err := x.Double()

            if got != tt.want {
                t.Errorf("expected %d, but %d", tt.want, got)
            }
            if tt.wantErr {
                if err == nil {
                    t.Errorf("expected error")
                }
            }
        })
    }
}

// テスト全体の setup, teardown 関数を指定
func TestMain(m *testing.M) {
    setup()
    defer teardown()
    // *testing.M.Run で具体的なテスト関数を呼ぶ
    // https://pkg.go.dev/testing#M.Run
    m.Run()
}

// setup 関数
func setup() {
    fmt.Println("## setup func ##")
}

// teardown 関数
func teardown() {
    fmt.Println("## teardown func ##")
}

// ベンチマークテスト
func BenchmarkDouble(b *testing.B) {
    in := 1
    // *testing.B.N を指定すると、ベンチマークに充分な回数だけ処理を実行してくれる
    for i := 0; i < b.N; i++ {
        x := num.NewNum(in)
        x.Double()
    }
}

// GoDoc に反映
// Num 型の Double メソッドの Example として表示される
func ExampleNum_Double() {
    x := num.NewNum(1)
    got, err := x.Double()
    if err != nil {
        fmt.Printf("%+v\n", err)
    }
    fmt.Println(got)
    // output: 2
}

テスト実行結果

go test コマンドにテストしたいパッケージを指定して実行します。-v で詳細表示。Go はデフォルトでテスト結果のキャッシュを持つようですが、-count=1 と付与すると、テスト結果のキャッシュを無効にできます。

The idiomatic way to disable test caching explicitly is to use -count=1.

$ go test -v -count=1 sample.com/sample/sample/pkg/num
## setup func ##
=== RUN   TestDouble1
--- PASS: TestDouble1 (0.00s)
=== RUN   TestDouble2
--- PASS: TestDouble2 (0.00s)
=== RUN   TestDouble3
=== PAUSE TestDouble3
=== CONT  TestDouble3
=== RUN   TestDouble3/normal
=== RUN   TestDouble3/abnormal
--- PASS: TestDouble3 (0.00s)
    --- PASS: TestDouble3/normal (0.00s)
    --- PASS: TestDouble3/abnormal (0.00s)
=== RUN   ExampleNum_Double
--- PASS: ExampleNum_Double (0.00s)
PASS
## teardown func ##
ok      sample.com/sample/sample/pkg/num        0.002s

cmd go test - Test packages

cmd go test - Build and test caching

cmd go test - Testing flags

基本的なお作法

Go にテストコードとして扱ってもらうためには、以下ルールを守るべし、とのことです。

  • ファイル名を XXX_test.go とする
  • パッケージ名は、テスト対象コードのパッケージ名と同一とするか、XXX_test とする
  • 関数名を TestXXX とする
  • 関数は、引数として *t testing.T を持つシグネチャとなる

You write a test by creating a file with a name ending in _test.go that contains functions named TestXXX with signature func (t *testing.T). The test framework runs each such function; if the function calls a failure function such as t.Error or t.Fail, the test is considered to have failed.

go doc - Testing

パッケージ名については、以下の投稿が参考になります。同一パッケージ名とした場合、エクスポートされていない変数や関数にも、直接テストできる点がメリットとなります。

Test Code Package Comparison

Black-box Testing: Use package myfunc_test, which will ensure you're only using the exported identifiers.

White-box Testing: Use package myfunc so that you have access to the non-exported identifiers. Good for unit tests that require access to non-exported variables, functions, and methods.

Proper package naming for testing with the Go language

また、Go にはアサート文が用意されていません。その理由は以下と説明されています。アサート文があることが、逆にテスト結果の報告をわかりにくくしている、という思想でしょうか。なるほど、という感じ。

Go doesn't provide assertions. They are undeniably convenient, but our experience has been that programmers use them as a crutch to avoid thinking about proper error handling and reporting. Proper error handling means that servers continue to operate instead of crashing after a non-fatal error. Proper error reporting means that errors are direct and to the point, saving the programmer from interpreting a large crash trace. Precise errors are particularly important when the programmer seeing the errors is not familiar with the code.

go doc - Why does Go not have assertions?

アサート文を用意していない代わりに、helper function を使えと他記事で紹介されています。helper function については後述。

テストコードの具体的解説

具体的なテストコードの解説を、テスト関数毎に記述します。

シンプルなテストコード (TestDouble1, TestDouble2)

TestDouble1TestDouble2 がシンプルなテスト関数です。テスト対象コード内の処理を呼び、その処理結果を比較しているだけです。

なお、TestDouble1 では assertEqual という関数を helper function として利用しています。helper function とは、ただの自作した関数です。テストケースに応じた適切な比較処理をする、エラーメッセージを返す関数を自作しよう、という事になります。

Table Driven Test (TestDouble3)

ある種のテストケースにおいては、Table Driven Test の利用を推奨しています。以下が Table Driven Test に関する説明です。

Writing good tests is not trivial, but in many situations a lot of ground can be covered with table-driven tests: Each table entry is a complete test case with inputs and expected results, and sometimes with additional information such as a test name to make the test output easily readable. If you ever find yourself using copy and paste when writing a test, think about whether refactoring into a table-driven test or pulling the copied code out into a helper function might be a better option.

Given a table of test cases, the actual test simply iterates through all table entries and for each entry performs the necessary tests. The test code is written once and amortized over all table entries, so it makes sense to write a careful test with good error messages.

go wiki - TableDrivenTests

具体的な Table Driven Test の書き方は、スライス内で宣言した各テストケースの定義をイテラブルにテスト処理する、というものになります。そのために利用できる便利なメソッドが、testing パッケージ内に用意されています。

Table Driben Test において、*testing.T.Parallel() を利用すると各テストを並行に実行してくれます。ただし注意点があり、以下の投稿で紹介されています。具体的には、for 内のスコープで変数を初期化してあげる必要があります。

Go言語のTable Driven Testでt.parallel()によるテスト並行実行時、一部のテストケースしか評価されない問題について

for 文と goroutines を併用した場合の動作に関して解説記事。

go wiki - CommonMistakes

setup, tear down 処理の方法 (TestMain)

テスト全体の前後に処理を追加したい場合、func TestMain(m *testing.M) という関数を作り、その中で *testing.M.run メソッドを呼んであげます。TestMain 関数は、テストの main 関数の一部として実行され、*testing.M.run メソッドが実際の各テスト関数を呼ぶメソッドとなります。そのため Testmain 関数内で setup と tear down にあたる処理を実行してあげれば、結果としてテスト全体の前後に処理を追加できるようになります。

TestMain runs in the main goroutine and can do whatever setup and teardown is necessary around a call to m.Run. m.Run will return an exit code that may be passed to os.Exit. If TestMain returns, the test wrapper will pass the result of m.Run to os.Exit itself.

pkg testing - Main

テスト単体の前後に処理を追加したい場合、そのテスト関数内で実行させたい処理を呼んであげれば良いだけとなります。

ベンチマークテスト (BenchmarkDouble)

ベンチマークテストとして実行したい関数は func BenchmarkXxx(*testing.B) というシグネチャで作成します。

pkg testing - Benchmarks

-bench オプションをつけて go test コマンドを実行すると、ベンチマークテストの関数を実行してくれます。

$ go test -v -count=1 -bench=. -run=BenchmarkDouble sample.com/sample/sample/pkg/num
## setup func ##
goos: linux
goarch: amd64
pkg: sample.com/sample/sample/pkg/num
cpu: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
BenchmarkDouble
BenchmarkDouble-8       384570271                3.090 ns/op       # <= 384570271 回処理されている
PASS
## teardown func ##
ok      sample.com/sample/sample/pkg/num        1.512

カバレッジの取得

-cover または -coverprofile オプションをつけて go test を実行すると、カバレッジ率を取得してくれます。

$$ go test -v -coverprofile=coverage.out sample.com/sample/sample/pkg/num
## setup func ##
=== RUN   TestDouble1
--- PASS: TestDouble1 (0.00s)
=== RUN   TestDouble2
--- PASS: TestDouble2 (0.00s)
=== RUN   TestDouble3
=== RUN   TestDouble3/normal
=== PAUSE TestDouble3/normal
=== RUN   TestDouble3/abnormal
=== PAUSE TestDouble3/abnormal
=== CONT  TestDouble3/normal
=== CONT  TestDouble3/abnormal
--- PASS: TestDouble3 (0.00s)
    --- PASS: TestDouble3/normal (0.00s)
    --- PASS: TestDouble3/abnormal (0.00s)
=== RUN   ExampleNum_Double
--- PASS: ExampleNum_Double (0.00s)
PASS
coverage: 100.0% of statements       # <= カバレッジ率が表示される
## teardown func ##
ok      sample.com/sample/sample/pkg/num        0.002s  coverage: 100.0% of statements

-coverprofile オプションで出力されたファイルを go tools にかませると、出力結果を html にしてくれます。

# coverage.html というファイルが生成される
$ go tool cover -html=coverage.out -o coverage.html

go tools cover

GoDoc に反映

テストコード内に記載したコードを、GoDoc 内に表示される Examle のコードとして扱えます。

Examples are compiled (and optionally executed) as part of a package’s test suite.

As with typical tests, examples are functions that reside in a package’s _test.go files. Unlike normal test functions, though, example functions take no arguments and begin with the word Example instead of Test

go blog - Testable Examples in Go

Examle としたいコードの関数を、以下の命名規則で作成します。

func ExampleFoo()     // documents the Foo function or type
func ExampleBar_Qux() // documents the Qux method of type Bar
func Example()        // documents the package as a whole

Examle のテスト関数は、コード内で標準出力された値を、コメントアウト部分の // output: で指定された値と比較します。

As it executes the example, the testing framework captures data written to standard output and then compares the output against the example’s “Output:” comment. The test passes if the test’s output matches its output comment.

pkgsite パッケージで表示を確認すると、以下の通り Example として表示されています。

pkg testing - Examples