🔩 ねじき教室 Go と Web の教室

テストを書く

読了目安 約3分

競プロのサンプルケース照合を自動化して恒久化したものとしてテストを捉え、go test とテーブル駆動テストを手元で書きます。

この章の目次

競プロでは、提出前にサンプルケースを流し、出力例と見比べます。 テストは、この照合を自動化して、コードと一緒に保存しておく仕組みです。

サンプル照合との違い

競プロの照合と違う点は 2 つあります。

  • 自動化:期待値との比較をコードとして書くので、出力を目で見比べる代わりに、コマンド 1 つで全ケースを検証できます
  • 恒久化:テストコードはリポジトリに残り続けるので、半年後に誰かがその関数を書き換えたときも、同じ検証がそのまま走ります

_test.go と go test

go test は、テストを専用の形式でビルドして実行するコマンドです。 右のエディタでは動かないので、この章は手元で進めます(環境がなければ 学習環境を整える へ)。

まず、1 章で覚えた形でプロジェクトを作ります。

シェル
mkdir calc-demo
cd calc-demo
go mod init example.com/calc-demo

テストしたい関数を calc.go に書きます。

Go
// calc.go
package calc

func Sum(nums []int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

テストは、同じディレクトリの、名前が _test.go で終わるファイルに書きます。

Go
// calc_test.go
package calc

import "testing"

func TestSum(t *testing.T) {
	got := Sum([]int{1, 2, 3})
	if got != 6 {
		t.Errorf("Sum([1 2 3]) = %d, want 6", got)
	}
}

ルールは 3 つです。

  • ファイル名を _test.go で終える
  • 関数名を Test で始め、引数に *testing.T を取る
  • 期待と違ったら t.Errorf で報告する

比較のための特別な構文はなく、ただの if 文で書きます。 実行は go test です。

シェル
go test      # 成功すると ok と表示される
go test -v   # 個々のテストの成否も表示される

テストの価値は、わざと壊してみるとよく見えます。 total += ntotal -= n に変えて go test を実行すると、次のように失敗します。

テキスト
--- FAIL: TestSum (0.00s)
    calc_test.go:8: Sum([1 2 3]) = -6, want 6
FAIL

どのファイルの何行目で、何を期待して何が返ったかが、そのまま出力されます。 確かめたら、元に戻しておいてください。

テーブル駆動テスト

ケースを増やすとき、TestSum2TestSum3 と関数を増やしてはいきません。 テーブル駆動テストは、入力と期待値の一覧を struct の slice として持ち、ループで全ケースを検証する Go の定番の書き方です。

Go
// calc_test.go
package calc

import "testing"

func TestSum(t *testing.T) {
	tests := []struct {
		name string
		nums []int
		want int
	}{
		{"正の数", []int{1, 2, 3}, 6},
		{"空の slice", []int{}, 0},
		{"負の数を含む", []int{5, -3}, 2},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Sum(tt.nums)
			if got != tt.want {
				t.Errorf("Sum(%v) = %d, want %d", tt.nums, got, tt.want)
			}
		})
	}
}

ケースの追加は slice に 1 行足すだけなので、不具合を見つけるたびに「その不具合を再現するケース」を足していけます。 足したケースは以後ずっと検証されるので、同じ不具合が再発すれば go test が検出します。

動いているコードを変えるための道具

テストが必須とされる理由は、正しく書くためというより、速く変えるためです。 変更のたびに全機能を手で確かめるのは現実的でないので、壊していないことの確認を機械に任せます。 テストのないコードは、動いていても壊すのが怖くて触れず、変更の速度がそこで頭打ちになります。

ISUCON にも同じ構図があります。 高速化のためにアプリコードを書き換えるたびにベンチマーカーを回し、スコアと失敗の有無を確かめます(推測するな、計測せよ)。

試してみよう

  1. 上の calc-demo を作り、go test が通ることを確かめる。
  2. Sum をわざと壊して失敗の出力を読み、元に戻す。
  3. 最大値を返す Max(nums []int) (int, error)calc.go に追加する。空の slice にはエラーを返してください(5 章の復習です)。
  4. Max のテーブル駆動テストを書く。正常なケースに加えて、空の slice でエラーが返ることを確かめるケースも入れてください。

4 では、テーブルの struct に「エラーを期待するか」を表すフィールド(wantErr bool など)を足すのが定番です。