バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

GoのTestifyを使って独立したサブテストを実現する

こんにちは、開発2部の早瀬です。

私のチームではGoのTestifyを使用して、バックエンドのテストを実装しています。
少し前にテストの実装を見直して、サブテスト間が影響し合わずに独立して実行できるようにしたので、今回はその方法について紹介したいと思います!

Testifyについて

TestifyはGoのテストフレームワークで、アサーションやモックなど、テストを効率的に実装するための様々な機能を提供しています。

その中でも、私のチームではmockパッケージとsuiteパッケージをよく使用しています。

mockパッケージ

mockパッケージを使うことで、モックオブジェクトの定義やアサーションを簡単に行うことができます。

テスト対象のコード

type Dependency interface {
    DoSomething(arg string) string
}

type SampleService struct {
    Dependency Dependency
}

func NewSampleService(dependency Dependency) *SampleService {
    return &SampleService{
        Dependency: dependency,
    }
}

func (s *SampleService) Run(arg string) string {
    return s.Dependency.DoSomething(arg)
}

テストコード

// モックの定義
type DependencyMock struct {
    mock.Mock
}

func (m *DependencyMock) DoSomething(arg string) string {
    args := m.Called(arg)
    return args.String(0)
}

func TestRun(t *testing.T) {
    dependencyMock := new(DependencyMock)
    // DoSomethingメソッドが呼ばれたら"mocked result"を返すように設定
    dependencyMock.On("DoSomething", mock.Anything).Return("mocked result")

    // テストしたいメソッドの実行
    sampleService := NewSampleService(dependencyMock)
    result := sampleService.Run("test")
    log.Println("result: ", result)

    // モックの検証
    // 引数が"test"で呼ばれたことを検証
    dependencyMock.AssertCalled(t, "DoSomething", "test")
    // 呼ばれた回数を検証
    dependencyMock.AssertNumberOfCalls(t, "DoSomething", 1)
}

実行結果

$ go test -v main_test.go
=== RUN   TestRun
2023/11/26 18:13:40 result:  mocked result
--- PASS: TestRun (0.00s)
PASS

suiteパッケージ

suiteパッケージを使うことで、関連するテストケースをスイートとしてグループ化し、スイートごとにセットアップやティアダウンなどを定義することができます。

テストコード

type SampleTestSuite struct {
    suite.Suite
}

// テストメソッドの実行前に実行される
func (s *SampleTestSuite) SetupTest() {
    log.Println("SetupTest")
}

// テストメソッドの実行後に実行される
func (s *SampleTestSuite) TearDownTest() {
    log.Println("TearDownTest")
}

func (s *SampleTestSuite) TestExample1() {
    s.Run("subtest1", func() {
        log.Println("subtest1")

        s.Run("subtest2", func() {
            log.Println("subtest2")
        })
    })
}

func TestExampleTestSuite(t *testing.T) {
    suite.Run(t, new(SampleTestSuite))
}

実行結果

$ go test -v main_test.go
=== RUN   TestExampleTestSuite
=== RUN   TestExampleTestSuite/TestExample1
2023/11/26 18:30:10 SetupTest
=== RUN   TestExampleTestSuite/TestExample1/subtest1
2023/11/26 18:30:10 subtest1
=== RUN   TestExampleTestSuite/TestExample1/subtest1/subtest2
2023/11/26 18:30:10 subtest2
2023/11/26 18:30:10 TearDownTest
--- PASS: TestExampleTestSuite (0.00s)
    --- PASS: TestExampleTestSuite/TestExample1 (0.00s)
        --- PASS: TestExampleTestSuite/TestExample1/subtest1 (0.00s)
            --- PASS: TestExampleTestSuite/TestExample1/subtest1/subtest2 (0.00s)
PASS 

開発を進める中で遭遇した課題

上記で紹介したmocksuiteを使いながらテストの実装を進めていたのですが、その中でサブテスト内で生成されたデータやモックの実行結果が、後続のサブテストに影響してしまうという問題が発生していました。

DB操作による問題

過去のチームメンバーの記事でも紹介されているのですが、私のチームではテストスイートごとにgo-txdbを使用してDBへのコネクションを作成しています。
そのため、テストスイートごとにDBがロールバックされるので、テストスイート内でのDB操作が、別のテストスイートに影響することはありませんでした。

var sqlDB *sql.DB

type TestSuite struct {
  suite.Suite
}

func (s *TestSuite) SetupSuite() {
  txdb.Register("txdb", "postgres", os.Getenv("TEST_DB_URL"))
}

func (s *TestSuite) SetupTest() {
  sqlDB, err := sql.Open("txdb", uuid.New().String())
  if err != nil {
    log.Fatal(err)
  }
}

func (s *TestSuite) TearDownTest() {
  sqlDB.Close()
}

tech.buysell-technologies.com

しかし、1つのテストスイート内で複数のテストやサブテストがある場合は、テストごとにDBがロールバックされるわけではないです。そのため、あるテストでのDB操作が後続にテストに影響してしまうことがありました。

func (s *SampleTestSuite) TestExample1() {
    s.Run("subtest1", func() {
        // DB操作が伴う何かしらの処理
        // e.g. テストデータの作成、テスト対象のメソッド内でのDB操作
    })

    s.Run("subtest2", func() {
        // WARNING: subtest1の実行によりDBの状態が変わっている
    })
}

これにより、本来通るはずのテストが失敗したり、それを避けるために、テスト内で作成されたデータを明示的に削除する処理を記載する必要がありました。

func (s *SampleTestSuite) TestExample1() {
    s.Run("subtest1", func() {
        // DB操作が伴う何かしらの処理

        // 作成されたデータを削除する処理
        s.DB().Where("...").Delete(&struct{}{})
    })

    s.Run("subtest2", func() {
        // この場合はsubtest1の実行によってDBの状態に変化はない
    })
}

明示的にデータの削除処理を入れることで、テスト自体は成功するようになります。
ただ、どのデータを削除する必要があるかは、テスト対象の実装の内部で生成されるデータを正確に把握している必要があります。サブテストを追加するにしても、前後のサブテストに影響がないかを気にする必要があり、テストを実装するのにかなり時間がかかってしまうという状態でした。

モックの設定や実行による問題

モックに関しても同様の課題を抱えていました。

モックオブジェクトはsuite.Suiteを埋め込んだ構造体のフィールドにポインタとして保持して、全てのテストスイートで同じモックオブジェクトを使い回すようにしていました。

type DependencyMock struct {
    mock.Mock
}

type TestSuite struct {
    suite.Suite

    dependencyMock *DependencyMock
}

そのため、あるサブテストでのモックの設定や呼び出し結果が別のサブテストに影響して失敗したり、モックを設定するためにテスト対象の実装の内部で呼ばれるモックを把握する必要あるなど、DB操作と同様の問題が発生していました。

また、モックに関しては、引数も返り値もゼロ値でいいので、設定さえされていればOKというモックも多く存在していました。そのようなモックに対しても、サブテストごとに設定を行っていたので、設定が冗長になっているという問題も発生していました。

課題に対しての対応

これらの課題に対して、それぞれ下記のように対応しました。

DBのロールバック

DB操作に関してはサブテストごとにDBをロールバックする方針を取りました。

具体的には、私のチームではGORMを採用しているので、GORMのSavePointという機能を使用しています。

SavePointとはトランザクションの特定の地点を登録して、その地点までロールバックできる機能です。

tx := db.Begin()
tx.Create(&user1)

tx.SavePoint("sp1")
tx.Create(&user2)
tx.RollbackTo("sp1") // Rollback user2

tx.Commit() // Commit user1

このSavePointをサブテストごとに作成して、サブテストの終了時にそのサブテストのSavePointまでロールバックするようにしました。

今年の2月にTestifyにSetupSubTestとTearDownSubTestが追加され、サブテストごとにSetup/TearDownを定義できるようになりました。今回はそちらを使用して、SetupSubTestSavePointを作成、TearDownSubTestで作成したSavePointまでロールバックするようにしました。
サブテストがネストすることも考慮して、SavePointをStackで保持するようにしています。

私のチームではGORMを採用しているので、SavePointを作成する方針を取りましたが、別のORMを使用している場合でも、SetupSubTestでトランザクションを開始して、TearDownSubTestでロールバックするという実装をすることで、サブテストごとのロールバックを実現することができます。

サブテストごとにtxdbを使ってコネクションを作成する方法も検討しましたが、サブテストごとにコネクションを貼るのはテスト実行時のパフォーマンスに影響すると思い不採用にしました。

具体的な実装は下記になります。

SavePointを保持するためのStackの実装

type SavePointStack []string

var stack *SavePointStack

func (s *SavePointStack) Push(name string) {
    *s = append(*s, name)
}

func (s *SavePointStack) Pop() (string, error) {
    if s == nil || len(*s) == 0 {
        return "", fmt.Errorf("stack is nil or empty")
    }
    name := (*s)[len(*s)-1]
    *s = (*s)[:len(*s)-1]
    return name, nil
}

func (s *SavePointStack) NewSavePointName() string {
    return fmt.Sprintf("savepoint_%d", len(*s)+1)
}

サブテストごとにSavePointを作成する実装

var gormDB *gorm.DB

type TestSuite struct {
    suite.Suite
}

func (s *TestSuite) DB() *gorm.DB {
    return gormDB
}

func (s *TestSuite) SetupSubTest() {
    savepointName := stack.NewSavePointName()
    stack.Push(savepointName)
    s.DB().SavePoint(savepointName)
}

func (s *TestSuite) TearDownSubTest() {
    savepointName, err := stack.Pop()
    if err != nil {
        s.T().Fatal(err)
    }
    s.DB().RollbackTo(savepointName)
}

モックのデフォルト値設定とリセット

モックに関してもDB操作と同様に、サブテストごとにリセットする方針を取りました。

具体的にはmock.Mockを拡張した構造体を定義し、その構造体に対してResetというメソッドを作成しました。そのメソッドをTearDownSubTestで実行することで、サブテスト終了時にモックの状態をリセットするようにしています。

また、同じモックの設定をサブテストごとに行うことで冗長になっている問題に対しては、デフォルト値を設定できるようにすることで対応しました。
モックの設定値であるExpectedCallsは先頭にあるものが優先して使用されるので、デフォルト値として設定したものは必ず末尾に存在するようにして、オーバーライドも実現できるようにしています。

具体的な実装は下記になります。

mock.Mockを拡張した構造体

type Mock struct {
    mock.Mock
    defaultCalls []*mock.Call
    mutex        sync.Mutex
}

func (m *Mock) OnDefault(methodName string, arguments ...any) *mock.Call {
    m.mutex.Lock()
    defer m.mutex.Unlock()

    c := m.Mock.On(methodName, arguments...)
    m.defaultCalls = append(m.defaultCalls, c)
    return c
}

func (m *Mock) On(methodName string, arguments ...any) *mock.Call {
    m.mutex.Lock()
    defer m.mutex.Unlock()

    // デフォルト値は常に末尾に存在するようにする
    if isSuffix(m.ExpectedCalls, m.defaultCalls) {
        m.ExpectedCalls = m.ExpectedCalls[:len(m.ExpectedCalls)-len(m.defaultCalls)]
    }
    c := m.Mock.On(methodName, arguments...)
    m.ExpectedCalls = append(m.ExpectedCalls, m.defaultCalls...)

    return c
}

func (m *Mock) Reset() {
    m.ExpectedCalls = []*mock.Call{}
    m.Calls = []mock.Call{}
    m.defaultCalls = []*mock.Call{}
}

func isSuffix(slice, suffix []*mock.Call) bool {
    if len(slice) < len(suffix) {
        return false
    }

    offset := len(slice) - len(suffix)
    for i, v := range suffix {
        if slice[offset+i] != v {
            return false
        }
    }

    return true
}

サブテストごとにデフォルト値の設定とリセットを行う実装

type DependencyMock struct {
    // 独自で定義したMock構造体
    Mock
}

type TestSuite struct {
    suite.Suite

    dependencyMock *DependencyMock
}

func (s *TestSuite) SetupSuite() {
    s.dependencyMock = new(DependencyMock)
}

func (s *TestSuite) SetupSubTest() {
    s.dependencyMock.OnDefault("DoSomething", mock.Anything).Return("default")
}

func (s *TestSuite) TearDownSubTest() {
    s.dependencyMock.Reset()
}

func (s *TestSuite) TestRun() {
    sampleService := NewSampleService(s.dependencyMock)

    s.Run("subtest1", func() {
        s.dependencyMock.On("DoSomething", mock.Anything).Return("overrided")
        result := sampleService.Run("test")
        log.Println("subtest1 result: ", result)
        // => subtest1 result:  overrided
    })

    s.Run("subtest2", func() {
        result := sampleService.Run("test")
        log.Println("subtest2 result: ", result)
        // => subtest2 result:  default
    })
}

func TestTestSuite(t *testing.T) {
    suite.Run(t, new(TestSuite))
}
$ go test -v main_test.go
=== RUN   TestTestSuite
=== RUN   TestTestSuite/TestRun
=== RUN   TestTestSuite/TestRun/subtest1
2023/11/26 20:38:57 subtest1 result:  overrided
=== RUN   TestTestSuite/TestRun/subtest2
2023/11/26 20:38:57 subtest2 result:  default
--- PASS: TestTestSuite (0.00s)
    --- PASS: TestTestSuite/TestRun (0.00s)
        --- PASS: TestTestSuite/TestRun/subtest1 (0.00s)
        --- PASS: TestTestSuite/TestRun/subtest2 (0.00s)
PASS

まとめ

今回はGoのTestifyを使って独立したサブテストを実行する方法について紹介しました。

これにより、テスト実装時に考慮しなければいけないことが減り、スムーズにテストを実装できるようになりました!
Goでテストを書いている方はぜひ参考にしてみてください。

最後にBuySell Technologiesではエンジニアを募集しています。興味がある方はぜひご応募ください!

https://herp.careers/v1/buyselltech/w0Rsl70Zlc4Lherp.careers herp.careers