TestProf(2) Rubyテストの遅いfactoryを診断治療する(翻訳)
はじめに
テスト関連のあらゆる問題を診断するTestProfを活用してRubyテストスイートの病を癒し、再び健康を取り戻す方法を学びましょう。今回はfactoryの診断について解説します。factoryによってテストの実行速度がどのように低下するか、その度合を測定する方法と回避する方法、そしてfactoryをfixture並に高速にする方法をご紹介します。
原文編集者注
本記事は、TestProfツールの利用方法更新を反映するため、2024年8月に更新されました。
TestProfは、Evil Martiansの多くのプロジェクトでTDD(テスト駆動開発)フィードバックループを回す速度を高めるために用いられており、テスト実行に1分以上かかるRailsアプリやRubyベースのアプリの改善になくてはならないツールです。TestProfはRSpecやminitestテスティングフレームワークの機能を拡張することで、どちらでも利用できます。
前回のTestProf紹介記事でTestProfをオープンソースプロジェクトとしてご紹介したときに、Ruby Webアプリケーションをテストするときに見落とされがちな問題、すなわち「factoryのカスケード問題」について記事をまるまる1本書くとお約束いたしました。本記事ではその約束を果たします。
TestProfを理解するには、TestProfを実際のテストで実行して詳しく調べる方がよいでしょう。RSpecでfactory_botを併用しているRailsプロジェクトなら、factoryはすぐ使える状態になっているでしょう。本記事ではインタラクティブに操作しながら学びますので、この先を読む前にTestProf gemをインストールしておくことをおすすめします。
TestProfのインストール手順はいたって単純で、以下のようにGemfileの:test
グループに1行追加すればおしまいです。
group :test do
gem 'test-prof'
end
🔗 factoryを理解する
アプリケーションをテストするときは、テストデータの生成が常に必要になります。このときによく使われるのが「factory」や「fixture」という2つの手法です。
factoryとは、事前定義済みのスキーマに沿って「(その時点で存在していない可能性もある)別のオブジェクトを動的に生成するオブジェクト」です。
もうひとつのfixtureは、さまざまな手法を表現できますが、(testデータベースに即座に読み込まれる)データの静的なステートを宣言し、テスト実行中は永続化されるのが普通です。
Railsの世界には、Rails組み込みのfixtureのほかに、factory_botやFabricationやその他のよく使われているサードパーティ製factoryツールもあります。
fixtureは静的な設計なので高速ですが、動的なfactoryの方が人気があります。
factoryのパフォーマンスはfixtureに十分太刀打ちできると私たちは信じているので、fixture的に使うのもありだと思います。方法については続きをお読みください。
🔗 EventProf
「factoryとfixtureのどっちが優れているか」という議論は一向に終結しそうにありませんが、私たちはfactoryの方が柔軟性が高く、テストデータをメンテナンス可能にするのに向いていると考えています。
ただし大いなる力には大いなる責任が伴います: factoryは自分の足を撃ち抜くことも、テストを激重にしてしまうことも可能なのです。
では、その大いなる力とやらを誤用しているかどうかをどのように判定すればよいでしょうか?そして誤用が見つかったときにどんな手を打てばよいのでしょうか?そのためには、まずテストがfactory部分でどれだけ時間を使ってしまっているかを可視化してみましょう。
そんなときにはかかりつけの「TestProf」先生の往診を頼むべきです。TestProof先生のカバンには各種診断ツールがぎっしり詰まっていますが、EventProfもそのひとつです。
EventProfはその名が表すとおりのイベントプロファイラで、factory.create
イベントのトラッキングを指示するときに使います。このイベントはFactoryBot.create()
を呼んでfactoryで生成したオブジェクトをデータベースに保存するたびに発火します。
EventProfはRSpecとminitestのどちらでも利用可能で、コマンドラインインターフェイスも備えているので、任意のRailsプロジェクトフォルダで以下のようにターミナルからイベントをトリガーできます(もちろん動かすにはテストとfactoryが必要ですし、本記事ではRSpecを前提としています)。
$ EVENT_PROF="factory.create" bundle exec rspec
...
[TEST PROF INFO] EventProf results for factory.create
Total time: 00:09.515 00:11.152 (85.00%)
Total events: 4891
Top 5 slowest suites (by time):
UsersController (users_controller_spec.rb:3) – 00:10.119 (581 / 248)
DocumentsController (documents_controller_spec.rb:3) – 00:07.494 (71 / 24)
RolesController (roles_controller_spec.rb:3) – 00:04.972 (181 / 76)
...
上の出力には、factoryからのレコード作成に要したトータル時間と、最も遅いspecが上位5件表示されています。
この出力で最も驚くべき点は、トータル時間の85%がfactoryに食われてしまっていることです。真面目な話、factoryが占める割合がこれほど高くなることは決して珍しくありません。
それでは皆さんもEventProfを実行して、気持ちを落ち着けながら数字を読んでください。私たちはこの問題の解決方法を知っています。
🔗 「カスケード」という名のゲーム
私たちが長年TestProfを使い続けきた経験と、テストに関連するほぼあらゆる事象をプロファイリングした結果によると、テストが遅くなる理由のひとつは、ほとんどが「factoryカスケード」という現象によるものであることがわかってきました。
それでは小さめのカスケードゲームをプレイしてみることにしましょう。
factory :comment do
sequence(:body) { |n| "Awesome comment ##{n}" }
author
answer
end
factory :answer do
sequence(:body) { |n| "Awesome answer ##{n}" }
author
question
end
factory :question do
sequence(:title) { |n| "Awesome question ##{n}" }
author
account # これはSaaSアプリケーション内のテナントだとします
end
factory :author do
sequence(:name) { |n| "Awesome author ##{n}" }
account
end
factory :account do
sequence(:name) { |n| "Awesome account ##{n}" }
end
さて、このコードではcreate(:comment)
の呼び出し後にデータベースのレコードが何件作成されるでしょうか?自分なりの答えを出したら、以下をお読みください。
- 最初に
comment
用のbody
を1つ生成します。この時点ではレコードが作成されません(得点: ゼロ)。 - 次に
comment
用のauthor
が1つ必要になります。author
はaccount
に属するべきなので、レコードは2件生成されます(得点: 2)。 comment
1つにつき「コメント可能な」オブジェクトが1つ必要になりますね(ここではanswer
)。answer
自身はauthor
1つとaccount
1つが必要です。これでさらに3件レコードが生成されます(得点: 2+2=4)。answer
にはquestion
も1つ必要です。そしてこのquestion
にも独自のauthor
1つと独自のaccount
が1つあります。さらに:question
factoryにはaccount
関連付けも1件あります(得点: 4+4=8)。- これで
answer
を生成可能になり、やっとcomment
自身を生成可能になります(得点: 8+2=10)。
以上です。つまり、答えはcreate(:comment)
でコメントを作成すると「データベースレコードが10件」生成されます。
たかがコメント1件をテストするためにaccount
やauthor
がいくつも必要でしょうか?おそらく不要でしょう。
ここでcreate_list(:comment, 10)
のようにコメントを複数生成したらどうなるかを想像してみましょう。「ヒューストンへ、こちら問題発生せり」
「factoryカスケード」ゲームが始まると、factory呼び出しのネストが原因で、大量のデータ生成を制御できなくなります。
カスケードは以下のようにツリーで表現できます。
comment
|
|-- author
| |
| |-- account
|
|-- answer
|
|-- author
| |
| |-- account
|
|-- question
| |
| |-- author
| | |
| | |-- account
| |
| |--account
この表現を「factoryツリー」と呼ぶことにしましょう。後でこれを分析に用いる予定です。
「炎よ、我とともに進め」1
EventProfではfactoryに費やしたトータル時間しか表示されていないので、何かがおかしいということはわかっても、コードを深堀りして推測を重ねなければ、どこを調べればいいのか見当がつきません。ありがたいことに、TestProf先生のカバンに別のツールがあるので、そんな作業を行わずに済みます。
もうひとつのプロファイラであるFactoryProfは、以下のように実行できます。
$ FPROF=1 bundle exec rspec
[TEST PROF INFO] Factories usage
total top-level name
1298 2 account
1275 69 city
524 516 room
551 549 user
396 117 membership
524 examples, 0 failures
結果のtotal
とtop-level
はそれぞれどう違っているのでしょうか。
total
は、factoryが「明示的に(create
呼び出しによる)」または別のfactory内で「暗黙に(関連付けやコールバックによる)」レコード生成に使われた回数を表す値です。
そしてtop-level
は、明示的な呼び出しと考えられる値のみを表します。
すなわちtop-level
とtotal
の値が著しく異なっていれば、そこでfactoryカスケードが発生している、つまり、factory自身による呼び出しよりも、他のfactoryからの呼び出しの方が頻繁に発生している可能性が考えられます。
ではその「他のfactoryたち」をどうやって特定すればよいのでしょうか。前述のfactoryツリーの助けを借りればよいのです。
ツリーをpre-order(先行順: NLR)で平らにして得られた結果を「factoryスタック」と呼ぶことにします。
// 前述のfactoryツリーから得たfactoryスタック
[:comment, :author, :account, :answer, :author, :account, :question, :author, :account, :account]
factoryスタックをプログラム的に得る方法は以下のとおりです。
FactoryBot.create(:thing)
が呼ばれるたびに、新しいスタックを1つ初期化する(最初の要素は:smth
):thing
の内側で別のfactoryが1つ使われるたびに、そのfactoryをスタックにプッシュする
スタックにすると何が嬉しいかというと、いわゆる「コールスタック」とまったく同様にフレームグラフを出力できるのです!ではフレームグラフよりいいものがあるとしたら、それは何でしょうか?
FactoryProfを使えば、インタラクティブに操作できるHTMLフレームグラフをすぐに生成できます。以下のコマンドラインを実行してみましょう。
$ FPROF=flamegraph bundle exec rspec
...
[TEST PROF INFO] FactoryFlame report generated: tmp/test_prof/factory-flame.html
生成されたファイルをブラウザで開くと以下が表示されます。
インタラクティブに操作できるFactoryFlameレポート
縦の各カラムは、1つのfactoryスタックを表します。カラム幅が広いほど、テストスイート内でこのスタックが呼び出された回数が多いことを示します。最下部のroot
セルは、トップレベルのcreate
呼び出しを表します。
FactoryFlameレポートでニューヨークの高層ビル群のようなものがそそり立っている場合、factoryカスケードが大量に発生しています(1つの摩天楼が1つのカスケードを表します)。
ゴッサムシティのようなFactoryFlame
🔗 「先生、私は生きられるんでしょうか?」
factoryカスケードを見つけただけでは不十分です。手術してfactoryカスケードを取り除く必要もあります。そのための手法をいろいろ検討してみましょう。
🔗 1: 明示的な関連付け
まず思いつくのは、factoryからすべての(あるいはほぼすべての)関連付けを取り除くことです。
factory :comment do
sequence(:body) { |n| "Awesome comment ##{n}" }
# 以下の関連付けを宣言しない
# author
# answer
end
この方法では、factory利用時に必要な関連付けを以下のようにすべて明示的に指定する必要があります。
create(:comment, author: user, answer: answer)
# さもないとこうなる
create(:comment) # => raises ActiveRecord::InvalidRecord
「じゃあ必要な引数を全部指定するのを避けたかったら、factoryを毎回厳密に使わないといけないってこと?」はい、おっしゃる通りです。この方法ならfactoryが高速になりますが、使い勝手は落ちます。
🔗 2: 関連付けを推論する
データベースの正規化レベルを下げるときによく使われる方法ですが、場合によっては関連付けを別のものから推論できることもあります。
factory :question do
sequence(:title) { |n| "Awesome question ##{n}" }
author
account do
# authorからaccountを推論する
author&.account
end
end
これならaccount
をいちいち作成せずにcreate(:question)
やcreate(:question, author: user)
のように書けます。
次のような「ライフサイクルコールバック」も使えます。
factory :question do
sequence(:title) { |n| "Awesome question ##{n}" }
transient do
author :undef
account :undef
end
after(:build) do |question, _evaluator|
# authorだけが指定された場合は、authorのaccountをaccountに設定
question.account ||= author.account unless author == :undef
# accountだけが指定された場合は、authorのownerをauthorに設定
question.author ||= account.owner unless account == :undef
end
end
この手法は非常に効率がよいのですが、その分大規模なリファクタリングが必要です(しかも率直に申し上げると、factoryが読みづらくなります)。
🔗 3: FactoryDefault
TestProfでは、カスケードを取り除くためのさらなる手段を用意してあります。それがFactoryDefaultです。
FactoryDefaultは、factory_bot拡張の一種であり、簡潔でエラーの発生しにくいDSLを用いて、レコードをfactory内部で暗黙に再利用することで、関連付けによるデフォルトを作成できるようにします。以下の例で考えてみましょう。
describe 'PATCH #update' do
let!(:account) { create_default(:account) }
let!(:author) { create_deafult(:author) } # 上で定義したaccountを暗黙に使う
let!(:question) { create_default(:question) } # 上で定義したaccountやauthorを暗黙に使う
let(:answer) { create(:answer) } # 上で定義したquestionやauthorを暗黙に使う
let(:another_question) { create(:question) } # 同じaccountとauthorを使う
let(:another_answer) { create(:answer) } # 同じquestionとauthorを使う
# ...
end
この手法には、既存のfactoryを変更する必要がないという大きなメリットがあります。テストにあるcreate(...)
呼び出しの一部をcreate_default(...)
に置き換えれば、それだけで作業は完了します。
FactoryDefaultには、関連付けられている可能性のあるfactoryレコードを特定するのに便利なプロファイラも組み込まれているので、これを使うことで闇雲にリファクタリングせずに済みます。
$ FACTORY_DEFAULT_PROF=1 bin/rspec
...
[TEST PROF INFO] Factory associations usage:
factory count total time
user 17 00:12.010
user[admin] 15 00:11.560
Total associations created: 33
Total uniq associations created: 3
Total time spent: 01:13.775
上のレポートで、関連付けられているレコードの作成にどのfactoryが使われたかを確認できます(つまり、そのfactoryはFactoryDefaultで再利用できる可能性がある)。
その代わり、この機能によってテストに「マジック(メタプログラミング)」が持ち込まれることになるので、人間が読みづらいテストにならないようご注意ください。FactoryDefaultの適用先を、トップレベルのエンティティ(マルチテナンシーアプリのテナントなど)のみに限定しておくのはよいアイデアです。
🔗 ボーナス: AnyFixture
ここまではfactoryカスケードの話ばかりでしたが、TestProfレポートでは他にどんな情報を得られるのでしょうか?
FactoryProfレポートをもう一度眺めてみましょう。
[TEST PROF INFO] Factories usage
total top-level name
1298 2 account
1275 69 city
524 516 room
551 549 user
524 examples, 0 failures
room
とuser
というfactoryの呼び出し回数が、exampleの総数とほぼ同じになっています。これはつまり、どちらのfactoryもすべてのexampleで必要とされていることになります。では、すべてのexampleで使うレコードを一括作成しておくというのはどうでしょう?こんなときはfixtureの出番です!
factoryは既に作成済みなので、それをfixture生成にも使い回せたら便利ですよね。そこで登場するのがAnyFixtureという機能です。
AnyFixtureは、どんなコードブロックでもデータ生成に利用でき、実行終了時にデータベースをクリーニングしてくれます。
AnyFixtureは、RSpecのshared contextsとの相性も完璧です。
# AnyFixture DSL(fixture)をrefinementで有効にする
using TestProf::AnyFixture::DSL
RSpec.shared_context "shared:user" do
let(:room) { fixture(:room) }
let(:user) { fixture(:user) }
end
RSpec.configure do |config|
# fixture作成は起動時に一括で行うのがおすすめ
config.before(:suite) do
fixture(:room) { create(:room) }
fixture(:user) { create(:user, room: fixture(:room)) }
end
config.include_context "shared:user", user: true
end
続いてこのshared contextを以下のように有効にします。
describe CitiesController, :user do
before { sign_in user }
# ...
end
AnyFixtureを有効にすると、以下のようなFactoryProfレポートが出力されます。
total top-level name
1298 2 account
1275 69 city
8 1 room
2 1 user
524 examples, 0 failures
いい感じに改善されましたよね。
受け入れテストでコンテキストをセットアップするのにAnyFixtureを使う現実のコード例については「AnyFixture shared account」をどうぞ。
factoryかfixtureか、どちらか1つに絞る必要はありません!両方使うのが賢い方法です。
最後にもう少しだけメモを追加しておきましょう。
factoryを使いこなすことで、テストデータをシンプルかつ柔軟に生成できるようになりますが、その代わりテストが非常にもろくなります。突然factoryカスケードが発生したり、生成が繰り返されて時間がかかりすぎたりする可能性があります。
皆さんもfactoryの手入れを怠らないようにし、定期的にTestProf先生のfactory健康診断を受けましょう。テストが高速になれば開発者たちが幸せになれます。
このプロジェクトを立ち上げた動機や、その他のユースケースについて詳しくは、前回のTestProf詳解記事をご覧ください。
Evil Martiansでは、外宇宙流の製品開発およびご相談を承ります。
関連記事
- 訳注: 原文見出しの「Fire, walk with me」はツイン・ピークスのサブタイトルの引用です。参考: ツイン・ピークス/ローラ・パーマー最期の7日間 - Wikipedia ↩
概要
原著者の許諾を得て翻訳・公開いたします。
本記事は以下の記事の続編です。日本語タイトルは内容に即したものにしました。