概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: TestProf II: Factory therapy for your Ruby tests — Martian Chronicles, Evil Martians’ team blog
- 原文公開日: 2018/05/25
- 著者: Vladimir Dementyev
- サイト: Evil Martians — ニューヨークやロシアを中心に拠点を構えるRuby on Rails開発会社です。良質のブログ記事を多数公開し、多くのgemのスポンサーでもあります。
本記事は以下の記事の続編です。日本語タイトルは内容に即したものにしました。画像は元記事からの引用です。
TestProf(2) Rubyテストの遅いfactoryを診断治療する(翻訳)
TestProfはEvil Martiansの多くのプロジェクトでTDD(テスト駆動開発)フィードバックループの回転数を高めるのに用いられており、テスト実行に1分以上かかるRailsアプリやRubyベースのアプリの改善になくてはならないツールです。TestProfはRSpecやminitestテスティングフレームワークの機能を拡張することで、どちらでも利用できます。
前回のTestProf紹介記事でTestProfをオープンソースプロジェクトとしてご紹介したときに、Ruby Webアプリケーションをテストするときに見落とされがちな問題、すなわち「factoryのカスケード問題」について記事を1本書くとお約束いたしましたが、やっとその約束を果たせるときが来ました。
TestProfを知るうえでは、TestProfを実際のテストで実行する方が理解しやすくなります。RSpecでfactory_bot(旧factory_girl)を併用しているRailsプロジェクトならfactoryはすぐ手の届くところにあります。本記事では実際に操作しながら解説しますので、本記事を読む前にfactory_bot gemをインストールしておくことをおすすめします。
TestProfのインストール手順はいたって単純で、以下のようにGemfileの:test
グループに1行追加すればおしまいです。
group :test do
gem 'test-prof'
end
factoryを分解する
アプリケーションをテストするときは、テストデータの生成が常に必要になります。このときによく使われるのが「factory」や「fixture」です。
factoryとは、事前定義済みのスキーマに沿って「(その時点で存在していない可能性もある)別のオブジェクトを動的に生成するオブジェクト」です。もうひとつのfixtureは、さまざまなアプローチを表現できますが、fixtureでは(testデータベースに即座に読み込まれる)データの静的なステートを宣言し、テスト実行中は永続化されるのが普通です。
Railsの世界には、Rails組み込みのfixtureのほかに、factory_botやFabricationやその他のよく使われているサードパーティ製factoryツールがあります。
「factoryとfixtureのどっちが優れているか」という議論は一向に終結しそうにありませんが、私たちはfactoryの方が柔軟性が高く、テストデータをメンテナンス可能にするのにふさわしいと考えています。
ただし大いなる力には大いなる責任が伴います: factoryは自分の足を撃ち抜くこともテストを鈍重にしてしまうこともできるのです。
では、その大いなる力とやらを誤用しているかどうかをどのように判定し、誤用が見つかったときにどんな手を打てばよいのでしょうか?そのためには、まずテストがfactory部分でどれだけ時間を使ってしまっているかを可視化してみましょう。
前回の記事では、データベースとのやりとりにかかる時間をEventProfで測定しました(訳注: 現在EventProf単体ではGitHubにはなく、TestProfの一部にふくまれているようです)。
そんなときにはかかりつけの「TestProf」先生の往診を頼むべきです。TestProof先生のカバンには診断ツールがぎっしり詰まっていますが、EventProfもそのひとつです。EventProfはその名が表すとおりのイベントプロファイラで、factory.create
イベントのトラッキングを指示するのに用いられます。このイベントはFactoryBot.create()
を呼んでfactoryで生成したオブジェクトをデータベースに保存するたびに発火します。
EventProfはRSpecとminitestのどちらにでも使え、コマンドラインインターフェイスも備えているので、任意のRailsプロジェクトフォルダで以下のようにターミナルからイベントを発火できます(もちろん動かすにはテストとfactoryが必要ですし、本記事ではRSpecを前提としています)。
$ EVENT_PROF="factory.create" bundle exec rspec
上の出力には、factoryからのレコード作成に要したトータル時間と、specが遅い順に5件表示されます。
[TEST PROF INFO] EventProf results for factory.create
Total time: 03:07.353
Total events: 7459
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)
Finished in 6 minutes 36 seconds (files took 32.79 seconds to load)
3158 examples, 0 failures, 7 pending
私たちのリファクタリング前のプロジェクトを用いた現実の実行例では、テストの実行に6分半、テストデータ生成に3分以上かかりました。つまりテスト時間のほぼ半分がデータ生成に費やされていたのです!しかしこれだけではありません。私が携わっているコードベースの中には、factoryからのレコード生成にテスト時間の80%を費やしているものすらあったのです。
ここでいったん気持ちを静めましょう。これから修正方法を解説します。
「カスケード」という名のゲーム
数年前に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ツリー」と呼ぶことにしましょう。後でこれを分析に用いる予定です。
「炎よ、我とともに進め」
訳注: 原文見出しの「Fire, walk with me」はツイン・ピークスのサブタイトルの引用です。
参考: ツイン・ピークス/ローラ・パーマー最期の7日間 - Wikipedia
EventProfではfactoryに費やしたトータル時間しか表示されていないので、何かがおかしいということはわかっても、コードを深堀りして推測を重ねなければどこを調べればいいのかがわかりません。幸い、TestProf先生のカバンに別のツールが見つかったので、そんな作業を行わずに済みます。
もうひとつのプロファイラはFactoryProfで、以下のように実行できます。
$ FPROF=1 bundle exec rspec
これで以下のようにfactoryの全リストと利用統計情報が出力されます。
[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
The output contains a path to an HTML report:
[TEST PROF INFO] FactoryFlame report generated: tmp/test_prof/factory-flame.html
生成されたファイルをブラウザで開くと以下が表示されます。
インタラクティブに操作できるFactoryFlameレポート
縦の各列は1つのfactoryスタックを表します。カラム幅が広いほど、テストスイート内でこのスタックが呼び出された回数が多いことを示します。最下部のroot
セルは、トップレベルのcreate
呼び出しを表します。
FactoryFlameレポートでニューヨークの高層ビル群のようなものがそそり立っている場合、factoryカスケードが大量に発生しています(1つの摩天楼が1つのカスケードを表します)。
ゴッサムシティのようなFactoryFlame
「先生、私は生きられるんでしょうか?」
factoryカスケードを見つけて終わるのではなく、手術してfactoryカスケードを取り除く必要があります。そのための手法をいろいろ検討してみましょう。
明示的な関連付け
まず思いつくのは、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が高速になるものの、使い勝手が落ちます。
関連付けのインターフェイス
データベースの正規化レベルを落とすときによく使われる方法ですが、場合によっては関連付けを別のものから推論できることもあります。
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が読みづらくなります)。
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の適用先をトップレベルのエンティティ(マルチテナンシーアプリのテナントなど)のみに限定しておくのはよいアイデアです。
ボーナス: 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
shared_context "shared user", user: true do
# AnyFixtureはトランザクションの外で呼び出すこと
# (example間で同じデータを再利用するため)
before(:all) do
fixture(:user) { create(:user, room: room) }
end
let(:user) { fixture(:user) }
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
いい感じに改善されましたよね。
factoryかfixtureかの二択ではありません!両方使うのが賢い方法です。
本記事をお読みいただいた皆さんに感謝いたします。
factoryを使いこなすことで、テストデータをシンプルかつ柔軟に生成できるようになりますが、その代わりテストが非常にもろくなります。factoryカスケードはどこからともなく侵入し、オブジェクトを繰り返し生成するとテストに多くの時間がかかってしまいます。
どうか皆さんもfactoryの健康にはご注意ください。そして定期的にTestProf先生のfactory健康診断を受けましょう。テストの高速化は開発者の幸福でもあります。
このプロジェクトを立ち上げた動機や、その他のユースケースについて詳しくは、TestProf詳解記事をご覧ください。
Evil Martiansでは、外宇宙流の製品開発およびご相談を承ります。