かまたま日記3

プログラミングメイン、たまに日常

t.Runでネストしたテストを実行するときの注意点

TL; DR

t.Run でネストしたテストを実行する時、Runに渡す関数はトップレベルのテストが実行されているのと別のgoroutineで実行されているのでParameterized Testなんかをやってる場合、変数のスコープに注意しましょう。

詳細

Goでテストをテストケースを書く際に以下のようにパラメータと期待値を指定してforでサブテストを実行する事がよくあると思います。

import (
    "fmt"
    "testing"
)

func Add(x, y int) int {
    return x + y
}

func TestAdd(t *testing.T) {
    tests := []struct {
        x, y     int
        expected int
    }{
        {x: 1, y: 2, expected: 3},
        {x: 2, y: 3, expected: 5},
        {x: 50, y: 50, expected: 100},
    }
    for _, tc := range tests {
        t.Run(fmt.Sprintf("%d+%d", tc.x, tc.y), func(t *testing.T) {
            if actual := Add(tc.x, tc.y); actual != tc.expected {
                t.Errorf("expected: %d actual: %d", tc.expected, actual)
            }
        })
    }
}

これを高速化のため t.Parallel を追加します。

func TestAdd(t *testing.T) {
    t.Parallel() // Add
    tests := []struct {
        x, y     int
        expected int
    }{
        {x: 1, y: 2, expected: 3},
        {x: 2, y: 3, expected: 5},
        {x: 50, y: 50, expected: 100},
    }
    for _, tc := range tests {
        t.Run(fmt.Sprintf("%d+%d", tc.x, tc.y), func(t *testing.T) {
            t.Parallel() // Add
            t.Logf("%+v", tc)
            if actual := Add(tc.x, tc.y); actual != tc.expected {
                t.Errorf("expected: %d actual: %d", tc.expected, actual)
            }
        })
    }
}

これを実行すると成功します。

ただし、以下のようにテストで渡されたパラメタを出力してみると、すべて {x:50 y:50 expected:100} であることが分かります。つまり最初の2つのテストケースは意図したように動いてないのです。

   for _, tc := range tests {
        t.Run(fmt.Sprintf("%d+%d", tc.x, tc.y), func(t *testing.T) {
            t.Parallel()
            t.Logf("%+v", tc) // Add
            if actual := Add(tc.x, tc.y); actual != tc.expected {
                t.Errorf("expected: %d actual: %d", tc.expected, actual)
            }
        })
    }

原因と対応

なぜこういう事が起こるかというと、t.Runに渡されたfunctionは別のGoroutineで実行され、 t.Parallel をつけることで各テスト関数が並列に実行されるため、for文のスコープで初期化されている tc の値は最後に代入された {x:50 y:50 expected:100} の値をすべてのfunctionが参照してしまうことになるためです。

これを修正するためにはfor文の中でもう一度 tc をすればOKです。

   for _, tc := range tests {
        tc := tc // Add
        t.Run(fmt.Sprintf("%d+%d", tc.x, tc.y), func(t *testing.T) {
            t.Parallel()
            t.Logf("%+v", tc)
            if actual := Add(tc.x, tc.y); actual != tc.expected {
                t.Errorf("expected: %d actual: %d", tc.expected, actual)
            }
        })
    }

for文の中で直接goroutineやdeferを実行するときはIDEIntelliJが注意してくれるので意識できてたのですが、テストに関しては完全に盲点で1時間ほど時間を溶かしてしまいました..orz

参考

Frequently Asked Questions (FAQ) - The Go Programming Language