今日の酒を旨く呑む

酒は努力した分きっと旨いはず。エンジニア見習いです。

【Go】Go1.14で追加されたt.Cleanup()の基本の使い方

golangのv1.14からtestingパッケージにt.Cleanup()が追加されました。

golang.org これはテストの初期化などの後処理をシンプルにするための関数です。
どのような場面で使うか基本的な例を実際に書いてみます。

使い方

恐らく大抵、単体テストなどではテストの依存関係を設定し、テストを実行、その結果を期待する値と比較するような順序でテストを書きます。
この依存関係についてリポジトリやDB、プロセスなどに状態を持つものの場合、 それらをテストケースごとに初期化(復元)する必要があります。

依存関係を初期化(復元)しない場合、テストやサブテスト同士の結果が影響して、テストが失敗したり意図せぬ結果になってしまいます。

DBにデータをInsertするようなリポジトリの例で考えてみます。 この場合、テストごとにInsertした内容を初期化しないとサブテストや、テスト結果をキャッシュした場合にデータが残ってしまう結果になります。

まずテストをシンプルにするために依存関係を設定する関数を書いてみます。
また、依存関係の初期化(復元)をするための関数を返却するようにします。

func NewRepository(t *testing.T) (*Repository, func()) {
    repository := &Repository {
        config : Config{
            // ...
        },
    }
    return repository, func () {
        if err := repository.Reset(); err != nil {
            t.Error("reset err, err:", err)
        }
    }
}

テスト本体はこの様になります。

func TestRepository_Insert(t *testing.T) {
    repository, reset := NewRepository(t)
    defer reset()

    ctx := context.Background()
    err := repository.Insert(ctx, User{
        UserID: int64(1),
        Name: "TestUser",
    })
    if err != nil {
        t.Error("insert error, err:", err)
    }
}

NewRepositoryで返ってきたreset()をdeferで呼び出すことで依存関係の復元を行っています。

この例ではdeferを必ず呼び出す必要があるので、この様な依存関係を初期化しなければいけないものが増えたり、複雑になるとテストコードを圧迫し始めます。 deferはテスト本体に書かないといけないので切り分けることも難しく、それぞれのdeferが必ず呼び出されることや、順番、どこかのdeferでパニックしないかなど考慮しなければいけません。
テストロジックに集中できなくなり可読性が下がっていきます。

これをt.Cleanup()を使って実装してみます。

func NewRepository(t *testing.T) *Repository {
    repository := &Repository {
        config : Config{
            // ...
        },
    }
    t.Cleanup(func() {
        if err := repository.Reset(); err != nil {
            t.Error("reset err, err:", err)
        }
    })
    return repository
}

func TestRepository_Insert(t *testing.T) {
    repository := NewRepository(t) // スッキリした!

    ctx := context.Background()
    err := repository.Insert(ctx, User{
        UserID: int64(1),
        Name: "TestUser",
    })
    if err != nil {
        t.Error("insert error, err:", err)
    }
}

戻り値としてのReset関数も消えて大分シンプルになりました!
メインのテスト本体に復元の処理を書く必要がなくなるのが一番大きな変化で、テストで考慮しなくて良いのはとても嬉しいですね。

挙動と並列処理 t.Parallel

基本的にはdeferのように動きます。

  1. テストの途中でpanic()したとしても通る
  2. 複数ある場合はスタックされているのでFILOの順で呼び出される

ただし、注意が必要なのがサブテストなどを並列で処理した場合です。
テストやサブテストはt.Parallel()を使うことで別ゴルーチンによって同時に実行することができます。
その時Cleanupをサブテストなどで使うと、トランザクションを使用していないので1つのサブテストの結果が他のテストに影響してしまいます。
別ゴルーチンの復元が終わっていなかったり、パニックすると呼び出されずに終了してしまいます。 t.Parallel()で実行する際はトランザクションを考慮した内容にする必要があるようです。

func NewRepository(t *testing.T) *Repository {
    repository := &Repository {
        config : Config{
            // ...
        },
    }
    t.Cleanup(func() {
        if err := repository.Reset(); err != nil {
            t.Error("reset err, err:", err)
        }
    })
    return repository
}

func TestRepository_Insert_Parallel(t *testing.T) {
    ctx := context.Background()
    for i := 0; i < 10; i++ {
        t.Run("test", func(t *testing.T) {
            t.Parallel()
            repository := NewRepository(t)

            err := repository.Insert(ctx, User{
                UserID: int64(1),
                Name:   "TestUser",
            })
            if err != nil {
                t.Error("insert error, err:", err)
            }
        })
    }
}

上記のコードにInsertとResetについて実行された時にログを出力するようにしました。 実行してみると以下のようなログが出力され、insertとresetがセットで実行されていないことが分かります。
(実行ログは一部です)

=== RUN   TestRepository_Insert_Parallel/test#06
=== PAUSE TestRepository_Insert_Parallel/test#06
=== CONT  TestRepository_Insert_Parallel/test#06
insert:8
    --- PASS: TestRepository_Insert_Parallel/test#06 (8.00s)
=== RUN   TestRepository_Insert_Parallel/test#07
=== PAUSE TestRepository_Insert_Parallel/test#07
=== CONT  TestRepository_Insert_Parallel/test#07
insert:9
reset:1
reset:2
reset:3
reset:4
reset:5
reset:6
reset:7
reset:8
reset:9

まとめ

deferよりも、簡単で可読性の高いテストが書けそうです。
個人的には並列で処理するテーブルテストばかり書いているので使う場面にはやや困るのですが、 場面によって使い分けたり、トランザクションを使った上手いテストを模索していこうと思います。