メインコンテンツまでスキップ

Go での単体テスト

実際に、Go で単体テストを行うにはどのようにするのかを見ていきます

テスト対象を作成

まずは、テスト対象の関数を作成します
ここでは例として、スライスの要素を逆順にしたものを返す Reverse() 関数を作成します

package slice

func Reverse(a []int) []int {
b := make([]int, 0, len(a))
copy(a, b)

for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}

return b
}
.
└── reverse.go

テストファイルを用意

Go でテストを行う際は _test という接尾辞をつけたファイルを用意します
その中で testing パッケージの testing.T を使用して以下のように書きます

package slice

import "testing"

func TestReverse(t *testing.T) {
// ここにテストを書いていく
}
.
├── reverse.go
└── reverse_test.go
パッケージについて

上記の例では slice パッケージにテストコードを書いていますが、テストの場合は package slice_test のように接尾辞に _test を付け加えたものにしても問題ありません

どちらの方法を取るのかは実装者やテスト内容によって変わります
以下にメリットデメリットをまとめたので、書く際の参考にしてみてください

  • テスト対象と同じパッケージ名称でテストコードを書く
    • メリット
      • 非公開な変数を変えたり、非公開な関数を呼び出したりテストしたりできる
    • デメリット
      • 非公開な変数や関数にアクセスできてしまう
        • 触る必要のない場合はアクセスできなくて良い
  • テスト対象とは別のパッケージ名称でテストコードを書く

テストコードを書く

Go では テーブル駆動テスト(Table Driven Test) と呼ばれる手法がよく用いられます

テーブル駆動テストはテーブルとテストを用意して、 データとロジックの分離 を目指す手法です

まずは以下のようにテストケースを用意します(これがいわゆるテーブルです)

// テストケースを用意
tests := []struct {
name string
in []int
want []int
}{
{"空スライスを渡した場合、空スライスが返る", []int{}, []int{}},
{"1要素の場合、同じスライスが返る", []int{10}, []int{10}},
{"5要素の場合、逆順に返る", []int{1, 2, 3, 4, 5}, []int{5, 4, 3, 2, 1}},
}

次に、各テストケースに対してテストを走らせます
テストを走らせる際は testing.T.Run() 関数にテスト名と実行する関数を渡します

// テストケースに対してテストを実行
for _, test := range tests {
test := test

t.Run(test.name, func(t *testing.T) {
got := Reverse(test.in)

// reflect.DeepEqualを使ってもOK
// なにをしているのか追いやすいようにsame()を自前で用意
if !same(got, test.want) {
t.Errorf("want %v, but %v:", test.want, got)
}
})
}
test := testについて

これはforループの変数が使い回されることが原因で起こる意図しない挙動を防ぐために書かれています
雑に言ってしまうと、Go1.21まではこの文を書いておけば良いおまじないのようなものです

以下に詳しく説明します

これが無いとどういった挙動になってしまうのか

t.Run() で渡しているクロージャ内で test が使用されています
仮にテストを並列で行うように t.Parallel() をそのクロージャ内で使用した場合、クロージャの宣言と実行のタイミングが異なるために、forループ終了時点でのテストケースで全てのテストが走ってしまいます

詳しくは、Goの並列テストでよくあるバグ(tt := tt忘れ)に対する対策 を見てみてください

Go1.22以降だとどうなるの?

Go1.22以降ではforループのセマンティクスが変更されるため、記述しなくてもよくなります

詳しくは、 Go1.22のリリース予定の機能を見る を見てみてください

テストコードの全体は以下のようになります

package slice

import "testing"

func TestReverse(t *testing.T) {
// テストケースを用意
tests := []struct {
name string
in []int
want []int
}{
{"空スライスを渡した場合、空スライスが返る", []int{}, []int{}},
{"1要素の場合、同じスライスが返る", []int{10}, []int{10}},
{"5要素の場合、逆順に返る", []int{1, 2, 3, 4, 5}, []int{5, 4, 3, 2, 1}},
}

// テストケースに対してテストを実行
for _, test := range tests {
test := test

t.Run(test.name, func(t *testing.T) {
got := Reverse(test.in)

// reflect.DeepEqualを使ってもOK
// なにをしているのか追いやすいようにsame()を自前で用意
if !same(got, test.want) {
t.Errorf("want %v, but %v:", test.want, got)
}
})
}
}

// スライスの要素が同じかどうかを判定する
func same(a, b []int) bool {
// nil check
if a == nil && b == nil {
return true
} else if a == nil || b == nil {
return false
}

// len check
if len(a) != len(b) {
return false
}

// value check
for i := range a {
if a[i] != b[i] {
return false
}
}

return true
}

では、これを go test コマンドで実行してみます

go test reverse.go reverse_test.go
--- FAIL: TestReverse (0.00s)
--- FAIL: TestReverse/1要素の場合、同じスライスが返る (0.00s)
reverse_test.go:27: want [10], but [0]:
--- FAIL: TestReverse/5要素の場合、逆順に返る (0.00s)
reverse_test.go:27: want [5 4 3 2 1], but [0 0 0 0 0]:
FAIL
FAIL command-line-arguments 0.002s
FAIL

なんとテストが失敗してしまっています
これはどういうことでしょうか

上記のメッセージには want [5 4 3 2 1], but [0 0 0 0 0]: と出力されており、全ての要素が0のスライスが返ってきていることがわかります

改めて Reverse() 関数を見てみましょう

func Reverse(a []int) []int {
b := make([]int, 0, len(a))
copy(a, b)

for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}

return b
}

どうも組み込み関数 copy() の挙動が怪しいです

ドキュメント を見てみると以下のように書いてあります

copy 組み込み関数は、要素をソース スライスから宛先スライスにコピーします。(特殊なケースとして、文字列からバイトのスライスにバイトをコピーすることもできます。) ソースと宛先は重複する場合があります。Copy は、コピーされた要素の数を返します。これは、len(src) と len(dst) の最小値になります。
https://pkg.go.dev/builtin#copy より翻訳して引用)

つまり、copy(a, b) ではなく copy(b, a) と書くのが正しそうです
以下のように reverse.go のコードを変更します

func Reverse(a []int) []int {
b := make([]int, 0, len(a))
- copy(a, b)
+ copy(b, a)

for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}

return b
}

これで改めて go test コマンドを実行します

go test reverse.go reverse_test.go
ok      command-line-arguments  0.001s

これでテストがちゃんと動いていそうです

なお、上記のテストでは補えていない部分の動作があります
それはどういったものでしょうか?ぜひ考えてみてください

想定解答
  • 引数として渡したスライスが変更されていないか