every Tech Blog

株式会社エブリーのTech Blogです。

Conveyを利用したGo test時にDuplicate entryが出たのでなんとかする

概要

TIMELINE開発部の内原です。

株式会社エブリーでは、バックエンド系ソフトウェアをGo言語で記述することが多いです。また、作成したプログラムについては、go testコマンドを用いてテストを記述するようにしています。

今回は、go test時に発生した一見分かりづらいエラーをどのように調査、対策したかについて共有します。

環境

  • Go言語
  • Test Frameworkとして Convey
  • DBはMySQL

エラー発生時の状況

ある時からローカル環境にてテストを実行したところ、以下のようなエラーが発生するようになりました。

$ go test ./repository/*.go
...
Line 30: - Error 1062: Duplicate entry '1' for key 'PRIMARY'
...

一見して、テスト中にレコードを登録しようとした際に、なんらかの理由でPrimary Keyの重複が発生したということが分かります。

ただ、この時は develop ブランチの先頭でテストを実行しており、同じコミットがCI環境では正常にテストが成功していたので不思議に思いました。

テストコードを見てみると以下のような実装になっていました。

import (
    . "github.com/smartystreets/goconvey/convey"
)

func setup() {
    err := insertRecord(1) // ID:1でレコードを登録
    if err != nil {
        panic(err)
    }
}

func teardown() {
    err := cleanupTable() // テーブルのレコードを全削除
    if err != nil {
        panic(err)
    }
}

func Test_Find(t *testing.T) {
    Convey("Find", t, func() {
        setup()
        repo := NewRepository()

        Convey("正常系", func() {
            record, err := repo.Find(1) // ID:1でレコードを検索

            So(err, ShouldBeNil) // エラーは発生しないことを検証
            So(record.ID, ShouldEqual, 1) // ID:1のレコードが取得できることを検証
        })

        Convey("異常系", func() {
            _, err := repo.Find(0) // ID:0でレコードを検索

            So(err, ShouldEqual, ErrNotFound) // NotFoundエラーが発生することを検証
        })

        Reset(func() {
            teardown()
        })
    })
}

conveyの使い方について簡単に説明します。

  • Convey() 関数
    • テストのコンテキストを表現する関数
    • 第一引数にはコンテキストの説明を、第二引数にはテスト対象の関数を渡す
    • コンテキストはネストすることができる
    • コンテキスト内部のテストコードでpanicが発生した場合、コンテキスト単位でテストは失敗したものとみなし、下位のコンテキストは実行されなくなる
  • So() 関数
    • assertionを行う関数
    • ShouldBeNil, ShouldEqual といったマッチャーを用いて記述することができる
  • Reset() 関数
    • コンテキスト単位でのテストの後処理を表現する関数
    • ネストしたコンテキストの場合、上位層も含めて実行される

調査

実際に、テスト実行前の状態でテスト用DBに接続してテーブルの中身を確認してみると、たしかにPrimary Keyが1のレコードが存在していました。 これでは当然 setup() で重複エラーが発生します。

しかし、 Reset()teardown() を呼び出すように実装しているので、テスト終了時にはテーブルの中身は空になっているはずでした。

その後記憶を頼りに原因を調べてみたところ、テスト終了時にもレコードが残ったままになっていた理由は単純なものでした。

develop ブランチの先頭でテストを実行する以前に、ローカル環境にて上記のテストコードと同じテーブルを操作する別のテストコードを実装していました。その際、誤って teardown() の呼び出しをしない状態でテストを実行しました。

func Test_Other(t *testing.T) {
    Convey("Other", t, func() {
        setup()
        repo := NewRepository()

        _, err := repo.DoSomething()
        So(err, ShouldBeNil) // エラーは発生しないことを検証
    })
}

このテストを実行したことでテスト終了時にレコードが残ったままになりました。

その後developブランチに移動した際もDBはそのままの状態であったため、setup() にて重複エラーが発生するようになっていたのでした。

  1. データが残ったままでテスト実行
  2. setup() が失敗
  3. Reset() が登録されない
  4. teardown() が呼び出されない
  5. データが残ったままでテスト実行
  6. setup() が失敗
  7. ...

というループに陥っていたということです。

対応案

要するに、1回でもレコードが残ったままになっているとその後テストデータの登録で必ず失敗し、またその状態から自力で復帰できないというテストコードになっていました。

このテストコードにおける問題点としては以下が挙げられます。

  • setup() が失敗することがある
  • setup()teardown() が1対1で対応するとは限らないため、テスト終了時にテストデータが残ったままになることがある

これらを解決する案としては以下が考えられます。

テーブルのレコードを削除する処理を setup() を実行する前に移動する

setup() が失敗しないようにするアプローチです。あらかじめテーブルを初期化しておきその後に登録という実装になるため、この場合 Reset() 自体不要になります。

登録の先に削除するか後に削除するかはどちらもあり得る実装だと思うのですが、すでに上記のようなコードが大量に存在したため、すべて修正するのは避けたいと考えました。

Reset() の呼び出しを setup() を実行する前に移動する

setup()teardown() が必ず1対1で実行されるようにするアプローチです。

setup() 内でpanicしようとも、すでに Reset() で登録されていればテスト終了時に teardown() が実行されることになります。

func Test_Find(t *testing.T) {
    Convey("Find", t, func() {
        Reset(func() {
            teardown()
        })

        setup()
        repo := NewRepository()
        ...
    })
}

ただ、convey のドキュメントを読むと Reset()Convey() の末尾で実行する例が記載されており、convey側の想定としては現状の実装のほうがマッチしているように見受けられます。

また前述の理由と同様に、修正箇所が多くなることは避けたいと考えました。

そもそも setup() 内でpanicしないようにする

setup() が失敗しないようにするアプローチです。

setup() でデータ登録が失敗した場合でも、 Reset() まで処理が継続するような実装にします。

例えば setup() を以下のように修正します。

func setup(t *testing.T) {
    err := insertRecord(1) // ID:1でレコードを登録
    if err != nil {
        t.Error(err)
    }
}
...
func Test_Find(t *testing.T) {
    Convey("Find", t, func() {
        setup(t)
        ...
        Reset(func() {
            teardown()
        })
    })
}

これなら、レコードが残っていて setup() でエラーが発生した場合でもテストは継続するので teardown() も実行されることになり、以降のテストでは正常に setup() が成功するようになります。

ただ、テストデータ登録処理のエラー判定は通常のテストとは区別しておきたいと考えました。この実装だとテストに失敗したのか、それともテストの前提処理で失敗したのかが伝わりづらくなるのではと感じました。

また、こちらも同様に修正箇所が多くなることが難点でした。

テスト実行時に1回だけテーブルを初期化する

setup() が失敗しないようにするアプローチです。少なくとも、テスト実行前にテーブルが初期化されていればテストコードが間違っていない限りエラーが発生することはなくなるという発想です。

懸念としては、テストに対し無関係のテーブルも初期化されるため実行パフォーマンスの悪化が考えられます。

ただ、Goのtestはパッケージ単位で実行されるため、その単位で初期化するだけならパフォーマンスへの影響はそこまで大きくはならないと考えました。

DBに直接依存したrepository系パッケージのみを対象とすればよく、全体からみるとその割合は高くないためです。

もちろんrepository系パッケージに依存しているパッケージ自体は別に存在しており、それらは間接的にDBに依存していると言えるのですが、これらパッケージの大部分はrepositoryをmockとして扱っており、直接DBに依存しているわけではなかったのでテーブルの初期化処理は不要でした。

そこで、テスト実行時に1回だけテーブルを初期化するため、repository系パッケージにおいて以下のようなコードを実装しました。

import (
    "github.com/khaiql/dbcleaner"
    "github.com/khaiql/dbcleaner/engine"
)

func TestMain(m *testing.M) {
    CleanupAllTables()
    os.Exit(m.Run())
}

func CleanupAllTables() {
    tables := allTables()
    dbCleaner := dbcleaner.New()
    dbCleaner.Clean(tables...)
}

func allTables() []string {
    tables := make([]string, 0)
    row, err := db.Query("SHOW TABLES")
    if err != nil {
        panic(err)
    }
    for row.Next() {
        var table string
        if err := row.Scan(&table); err != nil {
            panic(err)
        }
        if table == "migrations" { // DB migration用テーブルは除外
            continue
        }

        tables = append(tables, table)
    }
    return tables
}

テーブルの初期化には dbcleaner を使用しました。

テーブル数にもよりますが、100個程度のテーブルを初期化するのに要した時間は1秒程度でした。

前述の通り、影響を受けるのはDBに依存したパッケージのみであるため、許容範囲としました。

まとめ

今回はGo言語のテストコードにおいてテストデータ登録に失敗するようなケースでの対応について対応した結果をまとめました。 参考になりましたら幸いです。