Testing in Go: philosophy and tools

Ben Hoyt

Goにおけるテスト:哲学とツール

原文は Ben Hoyt により に公開されました。 このブログを購読する

Goプログラミング言語には、テストを記述し実行するためのツールが備わっている。標準ライブラリのtestingパッケージと、テストスイートを実行するためのgo testコマンドである。言語そのものと同様、Goのテストに対する哲学はミニマルだ。軽量なtestingパッケージと、素のGoで書かれたヘルパー関数を使うというものである。テストは単なるコードであり、Goの開発者はすでにGoの抽象化や型を使ってコードを書く方法を知っているのだから、テストを書くためだけの癖のあるドメイン固有言語を学ぶ必要はない、という考え方だ。

ブライアン・カーニハンとアラン・ドノバンによるThe Go Programming Languageは、この哲学を次のようにまとめている。テストに関する第11章からの引用である。

Go初心者の多くは、Goのテストフレームワークのミニマリズムに驚く。他言語のフレームワークは、テスト関数を識別する仕組み(しばしばリフレクションやメタデータを利用)、テスト実行の前後で「セットアップ」や「ティアダウン」を行うためのフック、そして一般的な述語のアサーション、値の比較、エラーメッセージの整形、失敗したテストの中断(しばしば例外を利用)のためのユーティリティ関数のライブラリなどを提供する。これらの仕組みによってテストを非常に簡潔に書けることもあるが、結果として得られるテストは、まるで外国語で書かれているかのように見えることが多い。

これが実際にどういうことかを見るために、testingパッケージと素のGoを使って絶対値を求める関数Abs()をテストするシンプルな例を示す。

    func TestAbs(t *testing.T) {
        got := Abs(-1)
        if got != 1 {
            t.Errorf("Abs(-1) = %d; want 1", got)
        }
    }

これと対比させたいのが、GoでRSpecスタイルのテストを書く手段を提供する、人気の(ただし筆者に言わせれば慣用的ではない)Ginkgoライブラリを使った次のバージョンである。

    Describe("Abs", func() {
        It("returns correct abs value for -1", func() {
            got := Abs(-1)
            Expect(got).To(Equal(1))
        })
    })

DescribeExpectといった関数はテストを「英語のように読める」ものにするが、まったく新しいサブ言語を学ばなければならないことを意味する。ドノバンのようなGoのコントリビューターの考えでは、言語にはすでに==!=といったツールが組み込まれているのだから、なぜTo(Equal(x))が必要なのか、ということになる。

とはいえ、Goは開発者がそうしたライブラリを使うことを妨げはしない。そのため、他言語から来た開発者にとっては、素のtestingよりもそうしたライブラリの方が馴染みやすいと感じることも多い。比較的軽量なライブラリとしては、assert.Equal()のような一般的なアサーション関数を追加するtestify/assertや、セットアップやティアダウンのようなテストスイート用ユーティリティを追加するtestify/suiteがある。「Awesome Go」のサイトでは、そうしたサードパーティ製パッケージの充実したリストが提供されている。

testingパッケージの一部ではないが有用なテストツールの一つにreflect.DeepEqual()がある。これはリフレクションを使って「深い等価性」を判定する標準ライブラリの関数で、ポインタをたどり、マップや配列などを再帰的に調べて等価性を判断する。JSONオブジェクトやポインタを含む構造体などをテストで比較する際に役立つ。これを基盤としたライブラリとして、Googleのgo-cmpパッケージと、Daniel Nichterによるdeepがある。これらはDeepEqualに似ているが、単に真偽値を返すのではなく、何が等しくないかを人間が読める差分として出力する。例えば、go-cmpを使ったMakeUsers()関数の(意図的に壊した)テストを次に示す。

    func TestMakeUser(t *testing.T) {
        got := MakeUser("Bob Smith", "[email protected]", 42)
        want := &User{
            Name:  "Bob Smith",
            Email: "[email protected]",
            Age:   42,
        }
        if diff := cmp.Diff(want, got); diff != "" {
            t.Errorf("MakeUser() mismatch (-want +got):\n%s", diff)
        }
    }

そして、人間が読める出力は次のとおりである。

    user_test.go:16: MakeUser() mismatch (-want +got):
          &main.User{
            Name:  "Bob Smith",
        -   Email: "[email protected]",
        +   Email: "[email protected]",
            Age:   42,
          }

組み込みのtesting機能

組み込みのtestingパッケージには、情報をログ出力したり失敗を報告したり、実行時にテストをスキップしたり、「short」モードでのみテストを実行したりするためのさまざまな関数が含まれている。shortモードは、実行に時間がかかったりセットアップが多かったりするテストをスキップする方法を提供し、開発中に役立つことがある。有効にするにはコマンドライン引数-test.shortを使う。

Goのテストランナーはデフォルトではテストを逐次実行するが、明示的にマークされたテストを複数のコアで同時に実行できるようにするオプトインのParallel()関数も用意されている。

Go 1.14では、testingパッケージにテストが完了したときに呼び出される関数を登録するCleanup()関数が追加された。これはティアダウンを簡素化するための組み込みの仕組みで、例えばテスト終了後にデータベースのテーブルを削除する場合などに使える。

    func createDatabase(t *testing.T) {
        // ... code to create a test database
        t.Cleanup(func() {
            // ... code to delete the test database
            // runs when the test finishes (success or failure)
        })
    }

    func TestFetchUser(t *testing.T) {
        createDatabase(t) // creates database and registers cleanup
        user, err := FetchUser("[email protected]")
        if err != nil {
            t.Fatalf("error fetching user: %v", err)
        }
        expected := &User{"Bob Smith", "[email protected]", 42}
        if !reflect.DeepEqual(user, expected) {
            t.Fatalf("expected user %v, got %v", expected, user)
        }
    }

Go 1.15では、現在のテスト用に一時ディレクトリを作成(そしてクリーンアップ)するテストヘルパーTempDir()が追加される。testingパッケージに追加するハードルは高いが、GoコアチームのRuss Coxはこの追加について承認を与えている。「It seems like temporary directories do come up in a large enough variety of tests to be part of testing proper.

テーブル駆動テスト

さまざまなエッジケースをテストする際に繰り返しを避けるための、Goでよく使われるイディオムは「テーブル駆動テスト」と呼ばれる。この手法では、テストケースを「slice」(Goにおける可変長配列へのビューを指す用語)の中で反復処理し、各反復で失敗があれば報告する。

    func TestAbs(t *testing.T) {
        tests := []struct {
            input    int
            expected int
        }{
            {1, 1},
            {0, 0},
            {-1, 1},
            {-maxInt, maxInt},
            {maxInt, maxInt},
        }
        for _, test := range tests {
            actual := Abs(test.input)
            if actual != test.expected {
                t.Errorf("Abs(%d) = %d; want %d", test.input, actual, test.expected)
            }
        }
    }

t.Errorf()の呼び出しは失敗を報告するがテストの実行を停止しないため、複数の失敗を報告できる。このスタイルのテーブル駆動テストは、標準ライブラリのテスト全体で一般的である(例えばfmtのテスト)。Go 1.7で導入されたサブテストという機能により、コマンドラインから個別のサブテストを実行したり、失敗や並列実行をより細かく制御したりできるようになった。

モックとインターフェース

Goのよく知られた言語機能の一つに、構造的型付けされたインターフェースがあり、「コンパイル時のダックタイピング」と呼ばれることもある。「Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here.」インターフェースは、実行時に振る舞いを変える必要があるときはいつでも重要であり、もちろんテストも例外ではない。例えば、GoのコアコントリビューターであるAndrew Gerrandが2014年の「Testing Techniques」トークのスライドで述べたように、ファイルフォーマットのパーサーは次のように具体的なファイル型を引数に取るべきではない。

    func Parse(f *os.File) error { ... }

代わりに、Parse()は必要な機能だけを実装した小さなインターフェースを取るべきである。このような場合、どこでも使われているio.Readerが良い選択肢となる。

    func Parse(r io.Reader) error { ... }

そうすれば、パーサーにはio.Readerを実装しているものなら何でも渡すことができ、ファイル、文字列バッファ、ネットワーク接続などが含まれる。また、テストもはるかに容易になる(おそらくstrings.Readerを使うことになる)。

テストで大きなインターフェースのごく一部しか使わない場合、例えば複数メソッドを持つAPIのうち一つのメソッドだけを使う場合などは、そのインターフェースを埋め込む新しい構造体型を作成してAPIの契約を満たし、呼び出されるメソッドだけをオーバーライドすることができる。この手法の完全な例はこのGo Playgroundのコードで示されている。

GoMockmockeryなど、インターフェース定義からモックコードを自動生成するさまざまなサードパーティ製ツールがある。しかし、Gerrandは手書きのフェイクを好んでいる

[mocking libraries like gomock]は問題ないが、総合的に見て手書きのフェイクの方が推論しやすく、何が起きているのかがより明確に見えると感じている。ただ、自分はエンタープライズ向けのGoプログラマーではないので、もしかするとそういうものを必要とする人もいるのかもしれない。わからないが、これが私の助言だ。

実行可能なサンプル

Goのパッケージドキュメントは、ソースコード内のコメントから生成される。コードコメント内でマークアップを多用するJavadocやC#のドキュメントシステムとは異なり、Goのアプローチでは、ソースコード内のコメントはソース上でも読みやすいままでなければならず、マークアップでごちゃごちゃにするべきではないという考え方だ。

ドキュメントのサンプルについても同様のアプローチが取られている。これらはテスト実行時に自動的に実行され、生成されるドキュメントに含められる、実行可能なコードスニペットである。Pythonのdoctestと同様、実行可能なサンプルは標準出力に書き出し、その出力が期待される出力と比較されることで、ドキュメント化されたサンプルのリグレッションを防ぐ。以下はAbs()関数の実行可能なサンプルの例である。

    func ExampleAbs() {
        fmt.Println(Abs(5))
        fmt.Println(Abs(-42))
        // Output:
        // 5
        // 42
    }

サンプル関数は*_test.goファイル内に置き、Exampleという接頭辞を付ける必要がある。テストランナーが実行されると、Output:コメントが解析され、実際の出力と比較され、異なればテストが失敗する。これらのサンプルは、例えばstringsパッケージに示されているように、生成されるドキュメント内で実行可能なGo Playgroundスニペットとして含められる。

ベンチマーク

テストに加えて、testingパッケージでは時間計測を伴うベンチマークを実行できる。これらは実行速度のリグレッションがないことを保証するために、標準ライブラリ全体で盛んに使われている。ベンチマークはgo test-bench=オプション付きで使うことで自動的に実行できる。著名なGoの著者であるDave Cheneyは、記事「How to write benchmarks in Go」で良いまとめを提供している。

例として、標準ライブラリのstrings.TrimSpace()関数のベンチマークを次に示す(テーブル駆動のアプローチと、サブベンチマークを作成するためのb.Run()の使用に注目)。

    func BenchmarkTrimSpace(b *testing.B) {
        tests := []struct{ name, input string }{
            {"NoTrim", "typical"},
            {"ASCII", "  foo bar  "},
            {"SomeNonASCII", "    \u2000\t\r\n x\t\t\r\r\ny\n \u3000    "},
            {"JustNonASCII", "\u2000\u2000\u2000☺☺☺☺\u3000\u3000\u3000"},
        }
        for _, test := range tests {
            b.Run(test.name, func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                    TrimSpace(test.input)
                }
            })
        }
    }

go testツールは数値を報告し、benchstatのようなプログラムを使って前後のタイミングを比較できる。benchstatの出力は、パフォーマンス改善を示すためにGoのコミットメッセージに含められることがよくある。例えば、変更152917からの引用だ。

    name                      old time/op  new time/op  delta
    TrimSpace/NoTrim-8        18.6ns ± 0%   3.8ns ± 0%  -79.53%  (p=0.000 n=5+4)
    TrimSpace/ASCII-8         33.5ns ± 2%   6.0ns ± 3%  -82.05%  (p=0.008 n=5+5)
    TrimSpace/SomeNonASCII-8  97.1ns ± 1%  88.6ns ± 1%   -8.68%  (p=0.008 n=5+5)
    TrimSpace/JustNonASCII-8   144ns ± 0%   143ns ± 0%     ~     (p=0.079 n=4+5)

これは、TrimSpaceのASCII向け高速パスによって、ASCIIのみの入力が約5倍高速になった一方で、「SomeNonASCII」サブテストは約9%遅くなったことを示している。

何かがどこで遅く実行されているかを診断するには、テスト実行時の-cpuprofileオプションのような組み込みのプロファイリングツールを使うことができる。組み込みのgo tool pprofは、フレームグラフを含むさまざまな形式でプロファイル出力を表示する。

go testコマンド

Goはテストがどこに置かれるか(*_test.goという名前のファイル内)やテスト関数の命名方法(Testという接頭辞を付ける必要がある)について意見を持っている。しかし、意見がはっきりしている利点は、go testツールがどこを見てどのようにテストを実行すればよいかを正確に把握できることだ。テストがどこにあるかを記述したmakefileやメタデータは必要ない。ファイルや関数が標準的な方法で命名されていれば、Goはすでにどこを見ればよいかを知っている。

go testコマンドは表面的にはシンプルだが、テストやベンチマークを実行・絞り込むための多くのオプションを備えている。以下にいくつかの例を示す。

    go test             # run tests in current directory
    go test package     # run tests for given package
    go test ./...       # run tests for current dir and all sub-packages
    go test -run=foo    # run tests matching regex "foo"
    go test -cover      # run tests and output code coverage
    go test -bench=.    # also run benchmarks
    go test -bench=. -cpuprofile cpu.out
                        # run benchmarks, record profiling info

Go testの-coverモードは、go tool cover -html=coverage.outを使ってHTMLとして表示できるコードカバレッジプロファイルを生成する。Goのコードカバレッジツールがどのように動作するかを説明する際に、Goの共同開発者であるRob Pikeは次のように述べている。

Go向けの新しいテストカバレッジツールでは、(バイナリをインストルメントするのとは)異なるアプローチを取り、動的デバッグを避けている。発想はシンプルだ。コンパイル前にパッケージのソースコードを書き換えて計測コードを追加し、変更されたソースをコンパイルして実行し、統計情報をダンプするのだ。書き換えは、goコマンドがソースからテスト、実行までの流れを制御しているため簡単に手配できる。

まとめ

Goのtestingライブラリはシンプルだが拡張可能であり、go testランナーはテスト実行、ベンチマーク、プロファイリング、コードカバレッジレポート機能を備えた良い補完関係にある。素のtestingパッケージだけでも十分遠くまで行ける。筆者は、Goのミニマルなアプローチが、テストについて異なる考え方を促し、インターフェースや構造体のコンポジションといった言語本来の機能を最大限に活用させる強制力になると考えている。しかし、サードパーティ製のライブラリが必要になったとしても、それらはgo getですぐに入手できる。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント