Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

TestProf(2) Rubyテストの遅いfactoryを診断治療する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

本記事は以下の記事の続編です。日本語タイトルは内容に即したものにしました。

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)


  • 初版公開: 2020/09/23
  • 大幅に更新: 2024/08/14

TestProf(2) Rubyテストの遅いfactoryを診断治療する(翻訳)

はじめに

テスト関連のあらゆる問題を診断するTestProfを活用してRubyテストスイートの病を癒し、再び健康を取り戻す方法を学びましょう。今回はfactoryの診断について解説します。factoryによってテストの実行速度がどのように低下するか、その度合を測定する方法と回避する方法、そしてfactoryをfixture並に高速にする方法をご紹介します。

原文編集者注

本記事は、TestProfツールの利用方法更新を反映するため、2024年8月に更新されました。

test-prof/test-prof - GitHub

TestProfは、Evil Martiansの多くのプロジェクトでTDD(テスト駆動開発)フィードバックループを回す速度を高めるために用いられており、テスト実行に1分以上かかるRailsアプリやRubyベースのアプリの改善になくてはならないツールです。TestProfはRSpecminitestテスティングフレームワークの機能を拡張することで、どちらでも利用できます。

前回の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_botFabricationその他のよく使われているサードパーティ製factoryツールもあります。

thoughtbot/factory_bot - GitHub

fixtureは静的な設計なので高速ですが、動的なfactoryの方が人気があります。
factoryのパフォーマンスはfixtureに十分太刀打ちできると私たちは信じているので、fixture的に使うのもありだと思います。方法については続きをお読みください。

🔗 EventProf

factoryとfixtureのどっちが優れているか」という議論は一向に終結しそうにありませんが、私たちはfactoryの方が柔軟性が高く、テストデータをメンテナンス可能にするのに向いていると考えています。


ただし大いなる力には大いなる責任が伴います: factoryは自分の足を撃ち抜くことも、テストを激重にしてしまうことも可能なのです。


では、その大いなる力とやらを誤用しているかどうかをどのように判定すればよいでしょうか?そして誤用が見つかったときにどんな手を打てばよいのでしょうか?そのためには、まずテストがfactory部分でどれだけ時間を使ってしまっているかを可視化してみましょう。

そんなときにはかかりつけの「TestProf」先生の往診を頼むべきです。TestProof先生のカバンには各種診断ツールがぎっしり詰まっていますが、EventProfもそのひとつです。

EventProfはその名が表すとおりのイベントプロファイラで、factory.createイベントのトラッキングを指示するときに使います。このイベントはFactoryBot.create()を呼んでfactoryで生成したオブジェクトをデータベースに保存するたびに発火します。

前回の記事では、データベースとのやりとりにかかる時間をEventProfで測定しました。

EventProfはRSpecminitestのどちらでも利用可能で、コマンドラインインターフェイスも備えているので、任意の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つ必要になります。authoraccountに属するべきなので、レコードは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件をテストするためにaccountauthorがいくつも必要でしょうか?おそらく不要でしょう。

実際のレコード数はこれよりずっと多くなる可能性があります。Active Recordのコールバックで生成した、関連付けのあるモデルの場合を考えてみるとよいでしょう(実際には絶対やらないこと)。

ここで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

結果のtotaltop-levelはそれぞれどう違っているのでしょうか。

totalは、factoryが「明示的に(create呼び出しによる)」または別のfactory内で「暗黙に(関連付けやコールバックによる)」レコード生成に使われた回数を表す値です。
そしてtop-levelは、明示的な呼び出しと考えられる値のみを表します。

すなわちtop-leveltotalの値が著しく異なっていれば、そこで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レポート

インタラクティブに操作できるFactoryFlameレポート

このグラフからどんなことを読み取れるでしょうか。

縦の各カラムは、1つのfactoryスタックを表します。カラム幅が広いほど、テストスイート内でこのスタックが呼び出された回数が多いことを示します。最下部のrootセルは、トップレベルのcreate呼び出しを表します。

FactoryFlameレポートでニューヨークの高層ビル群のようなものがそそり立っている場合、factoryカスケードが大量に発生しています(1つの摩天楼が1つのカスケードを表します)。

ゴッサムシティのようなFactoryFlame

ゴッサムシティのような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が高速になりますが、使い勝手は落ちます。

なお、必須ではない関連付けを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

roomuserという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では、外宇宙流の製品開発およびご相談を承ります。

関連記事

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

Rails 7 API: ActiveRecord::FixtureSet(翻訳)


  1. 訳注: 原文見出しの「Fire, walk with me」はツイン・ピークスのサブタイトルの引用です。参考: ツイン・ピークス/ローラ・パーマー最期の7日間 - Wikipedia 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。