はじめに
こんにちは。Repro で新規事業の開発をしている冨永です。
我々のチームでは主に、ユーザーのイベント集計を定期的にバッチ処理するフローで Go を採用しています。
Go で RDB など外部依存のあるコンポーネントを扱うテストをする際 interface などで抽象化しモックすることが多かったのですが、実際にその部分の挙動が確かめられないという不安がありました。
そこで今回は testfixtures というライブラリを使って実際に DB アクセスするテストを書いてみたのでその紹介です。
きっかけ
まずはチーム内でテストに関する共通認識を作るためワークショップを実施しました。
各々の『知りたいこと』『教えたいこと』『議論したいこと』を話し合った結果、以下のような話題が上がりました。
今回は特に『外部依存のあるコンポーネントでテストが書き辛い』というトピックが盛り上がり、その中でも『実際に書いたクエリが期待値通りの挙動をするか分からない』という課題については喫緊の課題であると判断しました。 そのため、別の日にさらに深く検討しました。
課題感
Goだとテスト時には環境の用意が難しい外部依存箇所は uber-go/mock 等でモックすることが一般的です。
モックを使った場合、どういったクエリが発行されるかといった部分に関してはテストでの担保が可能です。
一方、実際の RDB の操作については十分にテストがかけておらず、コマンドによる状態変化やクエリの結果が期待通りの内容を返すか、についてテスト状態に不安がありました。
(今回は sqlc を採用し、生クエリを扱っていることも、DB テストを書くモチベーションの1つにありました)
そこで今回、『RSpec で行われているような実際の RDB 操作』をすることでその結果を担保し、上記の不安を解消することをテストの目的としました。
具体例
まず、今回のように ORM を使っていない場合、テストデータを用意するのが面倒ということがあり、フィクスチャを使うことにしました。
使用するライブラリの候補としては複数あったのですが、今回は『更新頻度が高く』『対応する DB が多い』という理由で testfixtures を採用してみました。
(ライブラリの説明に「this package mimics the "Ruby on Rails' way"」とあり、Rspec っぽいテストができそうというのもきっかけの1つにありました)
なお、今回は 2024/07/26 時点で最新のバージョンである v3.11.0 を使用して動作確認をしています。
前提
実装例として、アカウントを管理する箇所のデータベースを想定します。
企業ごとにユーザーを管理するケースで、企業 (company) とユーザー (user) は 1 対多のリレーションをもっているものとします。
type Company struct { ID string Name string CreatedAt time.Time UpdatedAt time.Time } type User struct { ID string Name string CompanyID string CreatedAt time.Time UpdatedAt time.Time }
ここから『特定の名前の Company に紐付く全 User を取得する』というクエリを考えます。
例えば以下のようなクエリになります。
SELECT * FROM users LEFT JOIN companies ON users.company_id = companies.id WHERE companies.name = ?
しかし、クエリ自体を間違った場合でも、モックだとテストが通ってしまう可能性があります。
例えば以下のようになクエリになっていた場合です。(極端な例ではありますが)
SELECT * FROM users LEFT JOIN companies ON users.company_id = companies.id WHERE users.name = ?
これを testfixtures を使ったテストで防げるようにしてみます。
テストの準備
正しいテストにするためには testdata の設計が重要ですが、今回は以下のようなケースを作成しました。
(testdata/list_users_by_company_name.yml
の中身)
companies: - id: "7c5fy4v" name: "repro" created_at: RAW=NOW() updated_at: RAW=NOW() users: - id: "kvxprst" company_id: "7c5fy4v" name: "alice" created_at: RAW=NOW() updated_at: RAW=NOW() - id: "8a33n3u" company_id: "7c5fy4v" name: "bob" created_at: RAW=NOW() updated_at: RAW=NOW()
続いてテストコードを記載していきます。
(詳細については repository の usage をご覧ください)
// テスト用の db を用意する必要がある。 const TestDsn = "root:@tcp(127.0.0.1:43306)/test?parseTime=true" var ( // 実 db に依存するため並列実行出来ない。 db *sql.DB ) func TestMain(m *testing.M) { var err error db, err = sql.Open("mysql", TestDsn) if err != nil { panic(err) } os.Exit(m.Run()) } func TestCompaniesHavingNoUsers(t *testing.T) { prepareDatabase(t, "testdata/list_users_by_company_name.yml") defer truncateAllTableData(t) companyName := "repro" // sqlc の生成する Queries を wrap したもの。 q := queries.New(db) q := queries.New(db) res, err := q.ListUsersByCompanyName(context.Background(), companyName) if err != nil { t.Fatal(err) } // テストデータの yml に合わせる。 assert.Len(t, res, 2) } func prepareDatabase(t *testing.T, filename string) { fixtures, err := testfixtures.New( testfixtures.Database(db), testfixtures.Dialect("mysql"), testfixtures.FilesMultiTables(filename), ) if err != nil { t.Fatal(err) } if err = fixtures.Load(); err != nil { t.Fatal(err) } }
確認
これで先ほどの正しくないクエリで実行してみます。
=== RUN TestListUsersByCompanyName github.com/reproio/xxx/queries/queries_test.go:155: Error Trace: github.com/reproio/xxx/queries/queries_test.go:155 Error: "[]" should have 2 item(s), but has 0 Test: TestListUsersByCompanyName --- FAIL: TestListUsersByCompanyName (0.29s) FAIL FAIL github.com/reproio/xxx/queries 0.585s
期待値通り、テストが失敗することを確認できました。
次にクエリを修正すると、テストは正常に通ります。
-- 再掲 SELECT * FROM users LEFT JOIN companies ON users.company_id = companies.id WHERE companies.name = ?
ok github.com/reproio/xxx/queries 0.488s
これで『クエリの結果が期待通りの内容を返すか』をテストで確認できました。
今回は簡単な例ですが、より複雑なクエリを必要とする場合には安心できそうです。
まとめ
まず良かった点として、簡単に db の状態を setup/cleanup してくれるのは便利であると感じました。
また testfixtures 自体が多くの db を対象にしているため、将来の db 変更にも対応できそうです。
改善したい点としては、テーブルにある全カラムを埋めないといけないところがありました。関心ごとであるテスト対象以外はよしなに埋めて欲しいなと感じました。
▼ エンジニアの採用やっています! 興味あれば是非お声がけください https://herp.careers/v1/repro/requisition-groups/07e6d8e3-4222-45f0-8b02-4cabf43aed4a