💬

Goのテストと各種オプションについて

2024/02/24に公開

今回はGoのテストとそれに関わるオプションなどについて書いていきます。

Goのテストの基本

Goにはテスト用のフレームワークなどは用意されておらず、テストもGoそのものの書き方で書くことができます。フレームワークはありませんが、testingというテスト用の標準パッケージが用意されており、それを使ってテストを書いていくことになります。また他の多くの言語と異なり、テストファイル用ディレクトリを作成するのではなく、テスト対象のファイルが存在するディレクトリにテストファイルを作成する形を取ります。

Goのテストファイルはテスト対象ファイル名_test.goというファイル名にします。テスト関数はTestという文言で初め、*testing.T型の引数をとり、引数の名前は慣習でtとします。以下のようなコードがあるとします。

add.go
func add(x, y int) int {
    return x + y
}

このコードに対してGoのテストの基本に沿ってテストを書くと以下のようになります。

add_test.go
func TestAdd(t *testing.T) {
    result := add(5, 2)
    if result != 7 {
        t.Error("結果が間違っています:  想定される結果 7, 実際の結果", result)
    }
}

上の2つのファイルは同じディレクトリに配置します。
この状態でgo testコマンドを実行するとテストが実行されます。

テスト結果
PASS
ok  	go-test	0.306s

以下のようにコードを書き換えてテストを失敗させて、失敗時の挙動を確認してみます。

add_test.go
func TestAdd(t *testing.T) {
    result := add(5, 3) // テストが失敗するように引数を変更
    if result != 7 {
        t.Error("結果が間違っています:  想定される結果 7, 実際の結果", result)
    }
}
テスト結果
--- FAIL: TestAdd (0.00s)
    culc_test.go:11: 結果が間違っています:  想定される結果 7, 実際の結果 8
FAIL
exit status 1
FAIL	go-test	0.129s

簡単なテストですが、これがGoのテストの基本的な流れになります。

テスト結果の失敗の表示方法

テスト失敗を示すのにはt.Errort.Errorfもしくはt.Fatalt.Fatalfの2種類の関数を使い分けます。t.Errort.Errorfについてはエラーが発生したことを知らせた後にもテスト関数の実行を継続します。t.Fatalt.Fatalfについてはエラーが発生した場合その時点でテストを中断します。ただ中断されるのはあくまで一つのテスト関数だけで、残ったテスト関数は実行されます。そのため、テスト関数の中でそこが失敗するとそれ以降のテストも失敗することがわかっている場合はt.Fatalt.Fatalfを使用し、逆に独立した項目をテストする場合はt.Errort.Errorfを使うといった使い分けをすることで効率よくテストを行うことができます。それぞれの具体的なテストケースを書いてみます。

add.go
func add(x, y int) int {
    return x + y
}
add_test.go
func TestAdd(t *testing.T) {
    result := add(5, 2)
    if result != 7 {
        t.Error("結果が間違っています:  想定される結果 7, 実際の結果", result)
    }

    result := add(8, 8)
    if result != 16 {
        t.Error("結果が間違っています:  想定される結果 7, 実際の結果", result)
    }
}

上の2つのテストケースはそれぞれ独立しており、1番目のテストが失敗したからといって2番目のテストが失敗するとは限らないので、この場合はt.Errort.Errorfを使うべきです。

続いてt.Fatalt.Fatalfを使うケースを以下に2つ示してみます。

func TestDatabaseConnection(t *testing.T) {
    db, err := sql.Open("mydb", "mydb://user:password@localhost/mydb")
    if err != nil {
        t.Fatal("データベースへの接続に失敗しました:", err)
    }
    defer db.Close()

    // データベースに接続して行うテストケースを書く
}
func TestHTTPServer(t *testing.T) {
    server := http.Server{Addr: ":8080"}

    go func() {
        if err := server.ListenAndServe(); err != nil {
            t.Fatal("HTTPサーバーの起動に失敗しました:", err)
        }
    }()

    // HTTPリクエストに関するテストを書く
}

このようなデータベース接続やHTTP通信のテストなど、初期設定や前提条件が満たされなければ、後続のテストが意味をなさない場合にはt.Fatalt.Fatalf使用すべきです。それぞれの特徴を理解して、適切な使い分けができるようにしておきましょう。

テーブル駆動テスト

Goのテストではテーブル駆動テストという手法がよく取られます。テーブル駆動テストとはテストケースをスライスや配列などを使ってテーブル形式で表現し、それをループで回してテストを実行するテストパターンのことです。テーブル駆動テストには、テストコードの可読性を高めることや、新しいテストケースを簡単に追加できること、同じテストロジックの繰り返しを避けることができるなど様々なメリットがあります。
以下にテーブル駆動テストの例を書いてみます。

reverse.go
// 引数にとった文字列を逆さにする関数
func ReverseString(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}
reverse_test.go
func TestReverseString(t *testing.T) {
    // テストケースとなる構造体のスライスを定義
    tests := []struct {
        name string // テスト名
        input string // テスト対象の値
        want string // 期待する値
    }{
        {"空文字", "", ""},
        {"1文字", "a", "a"},
        {"英語", "hello", "olleh"},
        {"日本語", "こんにちは", "はちにんこ"},
        // 他にテストしたい値があれば追加する
    }

    // テストの実行
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := ReverseString(tc.input)
            if got != tc.want {
                t.Errorf("反転させる文字列:%q, 期待する文字列:%q, 得られた結果:%q", tc.input, tc.want, got)
            }
        })
    }
}

テストが成功すると以下のような結果が得られます。

=== RUN   TestReverseString
=== RUN   TestReverseString/空文字列
--- PASS: TestReverseString/空文字列 (0.00s)
=== RUN   TestReverseString/単一文字
--- PASS: TestReverseString/単一文字 (0.00s)
=== RUN   TestReverseString/単語
--- PASS: TestReverseString/単語 (0.00s)
=== RUN   TestReverseString/日本語含む
--- PASS: TestReverseString/日本語含む (0.00s)
--- PASS: TestReverseString (0.00s)
PASS
ok      go-test 0.282s

このようにテスト結果にテスト名が表示されるため、どのテストが成功し、どのテストが失敗したのかすぐに判別できるようにテスト名は何をテストしているのかわかりやすい名前にしましょう

コードを見てみるとよくわかりますが、テーブル駆動テストはテストケースが構造化されていて非常に見やすくなっており、網羅的なテストも書きやすいです。コードを見れば入力に対してどういう結果を得たいのかも簡単に理解できますし、新しいテストケースの追加も構造体に新しくテストしたい値を入れるだけで済みます。メリットの大きいテストパターンなのでGoでテストを書く際にはしっかりと活用していきたいですね。テーブル駆動テストにおけるテスト結果の表示については、基本的にループの途中で止めることは考えにくいのでt.Errort.Errorfを使用しましょう。

カバレッジチェック

Goにはプロダクトのコードをテストがどれだけカバーできているか(コードカバレッジ)を確認する機能があります。go testコマンドを実行する際に-coverを追加すると、テスト結果出力時にコードカバレッジを出力してくれます。

go test -coverの実行結果

PASS
coverage: 80.0% of statements
ok  	go-test	0.235s

80.0%というのがテストの網羅率であるカバレッジです。カバレッジの見方はわかりましたが、これだけではどこのコードに対してテストが行えていないのかがわかりません。Goにはどこでテストが実施できていないのかをファイルに出力する機能も備わっています。以下のコマンドを打つことでファイルを作成することができます。

go test -v -cover -coverprofile={出力ファイル名}

このコマンドを実行することで以下のような形式でファイルにテスト情報が出力されます。

mode: set
go-test/culc.go:4.37,6.57 2 1
go-test/culc.go:6.57,8.6 1 1
go-test/culc.go:9.5,9.25 1 1
go-test/culc.go:12.24,14.2 1 0

これだけだとかなり見にくいので、このファイルを可視化するツールも用意されています。

go tool cover -html=cover.out -o {出力してあるファイル名}.html

このコマンドを打つことで{出力してあるファイル名}.htmlというファイルが作成されます。これをブラウザで開くと以下のように表示されます。

緑の部分がテストをパスしたコード、赤の部分がテストをパスしていないコードです。このようにカバレッジを計測できたり、どこがテストできていないかを確認できるツールがあるのは便利ですね。ただカバレッジが高ければ品質も高いとは限らないですし、カバレッジが100%でも当然バグが存在する可能性が0になる訳ではないので、便利なツールは利用しつつも過信しないように注意しましょう。

テストのスキップ

テストの量が増えてくると、実行に時間がかかるテスト、かからないテストといった区別が出てきます。時間がかかるテストを常に実行していると開発効率の低下にも繋がるので、Goでは開発者の指定した時間のかかるテストをスキップできるオプションが用意されています。

add.go
func TestAdd(t *testing.T) {
+  if testing.Short() {
+      t.Skip("スキップします")
+  }
   result := add(5, 2)
   if result != 7 {
       t.Error("結果が間違っています:  想定される結果 7, 実際の結果", result)
   }
}
go test -shortを実行

=== RUN   TestAdd
    culc_test.go:31: スキップします
--- SKIP: TestAdd (0.00s)
PASS
ok  	go-test	0.134s

上のコードに追加しているようにtestingパッケージのShort()の機能を使ってスキップしたいテストの前に処理を追加し、テスト実行時に-shortオプションをつけることで対象のテストをスキップすることができます。時間のかかるテストを無駄に何度も実行してしまうのを防ぐためにもこの機能を活用していきましょう。

テストの前後処理

テストを行う際に実行前に特定の状態を作っておき、終了したらその状態を解除する必要がある場合TestMain関数を利用することができます。

func TestMain(m *testing.M) {
    // テスト前のセットアップ
    setup()
    // 全てのテストの実行
    code := m.Run()
    // テスト後の処理
    teardown()
    // テストの終了ステータスを返す
    os.Exit(code)
}

func setup() {
    // セットアップ用のロジック
}
func teardown() {
    // テスト後処理のロジック
}

func TestFirst(t *testing.T) {
    // テストの内容
}

func TestSecond(t *testing.T) {
    // テストの内容
}

TestMain関数を利用すると、テストを実行した際にテストが直接起動するのではなく、TestMain関数が呼び出される形になります。テスト関数はTestMain関数の中で*testing.MのメソッドRunが呼び出されたときに実行されるので、Runメソッドの前後に処理を書くことでテストの前後処理を行うことができるようになるのです。この機能は、例えば事前にデータベースなど外部とのやりとりを行う必要がある場合や複数のテストで前後に共通の処理を行う必要がある場合に活用できます。

テストの並行実行

Goにはテストを並行実行する機能も備わっています。これはtestingパッケージのParallelを使うことで実現できます。テーブル駆動テストの箇所で使用したテストを並行実行できるようにしたものが以下です。

func TestReverseString(t *testing.T) {
    tests := []struct {
        name string // テスト名
        input string // テスト対象の値
        want string // 期待する値
    }{
        {"空文字", "", ""},
        {"1文字", "a", "a"},
        {"英語", "hello", "olleh"},
        {"日本語", "こんにちは", "はちにんこ"},
        // 他にテストしたい値があれば追加する
    }

    // テストの実行
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
+           t.Parallel()
            got := ReverseString(tc.input)
            if got != tc.want {
                t.Errorf("反転させる文字列:%q, 期待する文字列:%q, 得られた結果:%q", tc.input, tc.want, got)
            }
        })
    }
}

テストを並行に行うことで、テスト時間を短縮できる可能性があります。ただ並行処理のため対象のテストの実行順序が保証されなくなるので順番に動かさなければ不具合が生じるテストには利用できません。出力もバラバラになるので他のテストもそうですが、特にどのテストが何をテストしているか分かりやすい命名にしておく必要があります。

最後に

今回はGoのテストの基本と周辺機能について書いてみました。色々と便利な機能が備わっているので、それも活用していいテストが書けるようになっていきたいです。最後までご覧いただきありがとうございました。

参考

https://www.amazon.co.jp/初めてのGo言語-―他言語プログラマーのためのイディオマティックGo実践ガイド-Jon-Bodner/dp/4814400047

https://www.amazon.co.jp/Go言語プログラミングエッセンス-エンジニア選書-mattn-ebook/dp/B0BVZCJQ4F/ref=sr_1_4?__mk_ja_JP=カタカナ&crid=KQGO56JP3WZX&dib=eyJ2IjoiMSJ9.psfVlwb_kOCw1f6Us3DlH2P2OVhlQRNJ_KG9DBQvWel4g5wtgRMdtRlOLYz5Y8Y-G_bRGKy5tDykqD3kOEN0gpJ5XEEY4dwMGledJgd7NdIxxD1S2IG0mtJBPDgmS2SUP7P_GJurwH_OK_06whvaBcvRbW4ehrWSRT2nP9513YFF_BL_MFYtDA_16D1df7Q_MexruagtwssALlNcpcpCzB-AI5oFlamslxphRjL26qA.yjEBKhej555TmxpNSuaacV1N4WIBdKsbz3fZz_FoDWU&dib_tag=se&keywords=Go言語プログラミング&qid=1708441676&s=books&sprefix=go言語プログラミング%2Cstripbooks%2C435&sr=1-4

Discussion