テスト爆速化の秘訣!pgtestdbのテンプレートクローン手法が凄すぎる
Pgtestdb's template cloning approach to testing is fast
Pgtestdb's template cloning approach to testing is fast
pgtestdbが採用している「テンプレートクローン」によるテスト環境構築手法が、とにかく高速で快適だったので紹介します。データベースのテスト環境作成に時間がかかって悩んでいるエンジニアは、ぜひチェックしてみてください。
テンプレートのクローン作成はIO負荷が高いから、PostgresをRAMディスク上に置くとさらに高速化できるよ。
うちのDBクリア処理は5msくらいかかるよ(おもちゃみたいな規模じゃなくて、成熟した会社の巨大なスキーマね)。まずは本番環境のスキーマダンプをリストアするところから始めて、テストDB/開発用DBのスキーマが本番と基本的に「同一」になるようにしてる。ブランチで作業中のマイグレーションは、リストア後に適用する流れ。ORMの定義からスキーマを構築するなんて、人生や時間を無駄にしたい人がやることだよ。テストDBのクリアも似たような時間。これはこれでいいし、テンプレートDBを使うのはローカルDBのスクラッチコピーをさくっと作ってマイグレーションをテストする良い方法だね(技術力があれば、破壊的変更の後のリストアにはrsyncを使うほうがいいけど)。
タイム計測:
テストDBをクリアする最速の方法は、クエリで全スキーマ/テーブル名を取得して、レプリカモードにしたDBに対して";".join("`DELETE FROM {schema}.{tablename};"を実行すること。これなら、かなりのテストデータがあってもほとんどの場合一桁ミリ秒で終わるよ。あらゆる方法を試したけど、テスト間のデータクリアにはこれが圧倒的に最速。
(SQLクエリのサンプル部分は割愛しますが、Postgresでどんなスキーマでも動作する汎用的なプロシージャです)
テストでSET LOCALや異なる分離レベルが必要ないなら、経験上、プロセスごとにテンプレートDBを用意して(一度マイグレーションしておいて、コピーの元にする)、最後にロールバックするトランザクションでテストをラップするのが一番速いよ。
Postgresのロールバックは実質的に一瞬。クリーンアップは後のVACUUMに任せればいいからね。Postgresは「ネストされたトランザクション」(セーブポイント)も扱えるから、ほとんどの場合コードを変更する必要もない。
トランザクションでラップできない一部のテストについては、各プロセスでシリアルに実行して、間にDELETEかTRUNCATE CASCADEを挟んでクリーンアップすればいい。DELETEの方が少し速いけど、外部キー制約の問題は自分で対処しないといけないね。
トランザクションロールバックに頼ると問題が起きることも多いけど、スピード面でこれより速い方法は今のところ知らないな。
リポジトリパターンを使ってテスト用にフェイク(モック)を用意する手軽さと比べて、ここまでする価値があるのかどうか、まだ納得がいかないんだよね。
もちろん、こういう努力をすべきじゃないとは言わないよ。将来的に技術的負債が解消されたり、安くて高速なQA環境やオールインワンのE2E環境に繋がるかもしれないしね。ただ、ユニットテストやサービステストのレイヤー、つまりテストピラミッドの下位数層に関しては、リポジトリインターフェースのフェイクを使うほうがずっと簡単で、コストも桁違いに安いと思う。
ここ数年、テストには「ダーティDB」アプローチを使ってる。Postgresベースのバックエンドを1つ用意して、テスト間にクリーンアップを一切せず、並列で統合テストを走らせるんだ。全テストが同じDBを叩くけど、それぞれユニークなIDを使う。制約は、正確なカウントをアサートするのではなく、特定のIDが存在することだけをアサートするようにすること。この構成ならDBのセットアップは一度だけで済むし、全テストを並列実行できる。本番環境で一番壊れやすいのは統合ポイントだから、実際のDBでテストするのは好きだよ。並列化のおかげで速いし、偶然だけどこのアプローチだと、本番での同時リクエストでしか発生しないようなレアな競合状態(いわゆるハイゼンバグ)もたまにキャッチできる。APIを並列で叩くテストは、結果的に本番挙動のシミュレーションとしても優秀だよ。
Brandur、pgtestdbをチェックしてこんなに徹底的にベンチマークしてくれてありがとう。パフォーマンスが良好で嬉しいよ。いつも全部破棄するんじゃなくて、成功したDBをクリーンアップして再利用するプールのような仕組みを実装するために、トークンと頭脳を割いてみるつもりだ。
下のスレッドで少し混乱があるようだけど――
pgtestdbはあくまで「テストにクリーンなDBを高速に提供する」ためのプリミティブだよ。RAMディスク上でPostgresを動かせば、クリーンでマイグレーション済みのDBを作るのにこれより速い方法はないと思う。しかも、並行して走らせているテストプロセスがいくつあろうと、その中で並列実行されているテストが何個あろうと、マイグレーションは一度しか走らないからね。
実際にはテストトランザクションと組み合わせることもできるし、DBでやりたいことは何でも好きにやっていいんだよ!僕の経験上では、かなり大量のテストであっても「全てのテストに専用のDBを割り当てる」というやり方で十分高速に動作するはずだよ。
AIの本当に素晴らしい点は、以前なら時間コストが見合わなかったような実験を可能にしてくれることだね。Brandur、改めてありがとう。
MySQLで同じようなことってできるのかな?
引き継いだRailsプロジェクトの元開発者が、db/seed.rbでテストDBをシードして、全テストをそのシード内容に依存させるっていう設計にしてたんだ。テスト数が膨大だったから、書き直しは即座には無理だった。
新しいテストは正攻法で書いたけど、単一のテストを実行する時でさえシードに時間がかかるっていう問題はずっと残ってた。だから、テスト終了時にDBをダンプして(まだ存在しなければ)、テスト開始時にそれをリロードするスクリプトをいくつか書いたんだ。これはずっと速いよ。ブランチを切り替える時はダンプリストがないから、結局DBをクリアしてシードし直さないといけないけどね。
いっそデータ入りのテンプレートを作るか、あるいはようやく全ての古いテストを書き直すときが来たのかも。
長い間、Postgresをテストする時には似たようなアプローチを使ってるよ。Testcontainersなんかを使うとき特にね。これのおかげで長年テストの待ち時間を大幅に短縮できたし、手動実装だとしてもめちゃくちゃ簡単だよ。
実際にアクションを実行するテストのほうが、モックやクラスの「テスト用実装」を多用するコードよりも遥かにバグを拾えるし、セットアップも簡単だと思う。ちょっと気を使えばパフォーマンスのペナルティなんて無視できるレベルだし、それ以上の価値は確実にあるね。
Integresqlはうちでもすごく役に立ってる。E2Eテスト中に一時的なPostgres DBをたくさん立ち上げてるよ。
https://github.com/allaboutapps/integresql