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

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

概要

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

本記事は以下の記事の続編です。日本語タイトルは内容に即したものにしました。画像は元記事からの引用です。

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

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

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

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

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

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

fixtureはfactoryよりも設計上高速ですが、factoryはfixtureよりも広く普及しています。しかし私たちは、factoryでも十分fixtureに匹敵するパフォーマンスを発揮できると信じていますし、factoryを「fixture的に」用いることすらあります。その方法をこれからご説明いたします。

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

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

前回の記事では、データベースとのやりとりにかかる時間をEventProfで測定しました(訳注: 現在EventProf単体ではGitHubにはなく、TestProfの一部にふくまれているようです)。

そんなときにはかかりつけの「TestProf」先生の往診を頼むべきです。TestProof先生のカバンには診断ツールがぎっしり詰まっていますが、EventProfもそのひとつです。EventProfはその名が表すとおりのイベントプロファイラで、factory.createイベントのトラッキングを指示するのに用いられます。このイベントはFactoryBot.create()を呼んでfactoryで生成したオブジェクトをデータベースに保存するたびに発火します。

EventProfはRSpecminitestのどちらにでも使え、コマンドラインインターフェイスも備えているので、任意の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つ必要になります。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ツリー」と呼ぶことにしましょう。後でこれを分析に用いる予定です。

「炎よ、我とともに進め」

訳注: 原文見出しの「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

結果の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

The output contains a path to an HTML report:

[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カスケードを取り除く必要があります。そのための手法をいろいろ検討してみましょう。

明示的な関連付け

まず思いつくのは、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から削除するのは常によいアイデアです。

関連付けのインターフェイス

データベースの正規化レベルを落とすときによく使われる方法ですが、場合によっては関連付けを別のものから推論できることもあります。

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

roomuserという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

いい感じに改善されましたよね。

受け入れテストでコンテキストをセットアップするのにAnyFixtureを使う現実のコード例については「AnyFixture shared account」をどうぞ。

factoryかfixtureかの二択ではありません!両方使うのが賢い方法です。


本記事をお読みいただいた皆さんに感謝いたします。

factoryを使いこなすことで、テストデータをシンプルかつ柔軟に生成できるようになりますが、その代わりテストが非常にもろくなります。factoryカスケードはどこからともなく侵入し、オブジェクトを繰り返し生成するとテストに多くの時間がかかってしまいます。

どうか皆さんもfactoryの健康にはご注意ください。そして定期的にTestProf先生のfactory健康診断を受けましょう。テストの高速化は開発者の幸福でもあります。

このプロジェクトを立ち上げた動機や、その他のユースケースについて詳しくは、TestProf詳解記事をご覧ください。


Evil Martiansでは、外宇宙流の製品開発およびご相談を承ります。

関連記事

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

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


CONTACT

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