こんにちは、開発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
開発を進める中で遭遇した課題
上記で紹介したmock
やsuite
を使いながらテストの実装を進めていたのですが、その中でサブテスト内で生成されたデータやモックの実行結果が、後続のサブテストに影響してしまうという問題が発生していました。
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() }
しかし、1つのテストスイート内で複数のテストやサブテストがある場合は、テストごとにDBがロールバックされるわけではないです。そのため、あるテストでのDB操作が後続にテストに影響してしまうことがありました。
func (s *SampleTestSuite) TestExample1() { s.Run("subtest1", func() { // DB操作が伴う何かしらの処理 // e.g. テストデータの作成、テスト対象のメソッド内でのDB操作 }) s.Run("subtest2", func() { // WARNING: subtest1の実行によりDBの状態が変わっている }) }
これにより、本来通るはずのテストが失敗したり、それを避けるために、テスト内で作成されたデータを明示的に削除する処理を記載する必要がありました。
func (s *ListItemsFutureShopTaskServiceTestSuite) 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を定義できるようになりました。今回はそちらを使用して、SetupSubTest
でSavePoint
を作成、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ではエンジニアを募集しています。興味がある方はぜひご応募ください!