TestProfでワースト5のspec実行時間を8割削減していった話

Findyでエンジニアをしている松村(@shakemurasan)です。

以前、弊社の栁沢が「RailsのCIのテスト実行時間を10分から5分に高速化した話」という記事を投稿しました。

tech.findy.co.jp

本記事ではその少し前のお話、そもそもRSpecの実行時間自体にまだまだあった伸びしろ、特にFactory周りの問題をTestProfというgemを活用して解消していった話となります。

当時のRSpecの実行時間状況

これまでにもテスト実行時間の短縮のための取り組み(CI側でのマシンの並列起動および並列実行)はしており、そこまでストレスのある環境ではありませんでした。異様に実行時間を要しているテストが存在することはメンバー間で認識しており、何となく原因のアタリはついていたものの、並列実行導入で快適な環境が得られていたので、テスト軽量化の優先度が落ちている状況でした。

しかし、じわじわとテスト実行時間は伸びていき、並列実行の導入当初は10分程度だったものが、15分を超えるケースが目立つようになってきました。いよいよテスト軽量化に着手しようかという話になり、実際に対応していくことになったのでした。

更に並列化を推進しようという案もその時点であったのですが、そもそも前述の「異様に実行時間を要しているテスト」が原因でマシンごとのテスト実行完了時間に度し難いバラツキが生じており、まずはここを解決しなければならないよねとなりました。

TestProfとは

テスト軽量化にあたって、まずは何となくアタリをつけていたところに本当に原因があるのか測定することにしました。解析/測定に際し、TestProfが使えそうということは様々な記事、他企業様のテックブログ事例から知っていたので、これを採用することにしました。

github.com

TestProfには様々な機能があるのですが、アタリをつけていたのはテストデータ生成周りだったため、FactoryProfというFactory周りの解析機能を使っています。FactoryProfを用いることで、Factory別の 総呼び出し回数 / 総実行時間 / 1回あたりの実行時間 あたりをレポーティングでき、この機能を用いて改善に着手しました。

※ この後の章で登場するファイル名、Model、Factoryは仮名に置き換えていますが、数値は実際のものを掲載しています

TestProfでの分析結果

まず大前提、時間がかかっているテストケースの状況を見てみます。

spec Before After Reduce
Worst1 78.37s ??? ???
Worst2 75.71s ??? ???
Worst3 48.76s ??? ???
Worst4 48.16s ??? ???
Worst5 88.01s ??? ???
Total of worst 339.01s ??? ???

実にテストケース5本で5分39秒を越えている状況で、これはちょっと辛いな.... というのがわかります。ここから、一番重たいテストケースのみを指定してFactoryProfの解析をかけてみます。

FPROF=1 bin/rspec spec/models/worst_spec.rb:2244

すると次のようなレポートが得られます。

[TEST PROF INFO] Factories usage

 Total: 1586
 Total top-level: 8
 Total time: 01:32.432 (out of 01:59.446)
 Total uniq factories: 12

   total   top-level     total time      time per call      top-level time      name

     309           0       80.7570s            0.2613s             0.0000s full_item
     103           0        0.8972s            0.0087s             0.0000s shop_user
     103           0       90.3245s            0.8769s             0.0000s item_like
     103           0       88.4511s            0.8587s             0.0000s      shop
...
...
...

この結果を見ると、item_like shop が呼び出し回数と1回あたりの実行時間あたりが1s弱と長いことがわかります。ここから段階的に改善の手を打っていきます。

改善1. 関連したレコードを複数件作成しているFactoryを1レコードに減らす

そこで実際のコードを読んでFactory間の依存関係を見てみると次のような状況でした。

item_like -- create --> shop ┬-- create --> full_item
                             ├-- create --> full_item
                             └-- create --> full_item

テストの要件は割愛させていただくのですが、テストを実行するうえで full_item は3件も必要なく、最低1件はshopにぶら下がっていれば十分という状況でした。 item_like というのはここ以外でも結構色々なところで使われているFactoryで、ほとんどのテストは最低1件のshopがぶら下がっていれば問題ない状況でした。

そこで、item_like のFactoryを修正して full_item を1件だけに限定して再解析したところ、次のような結果が得られました。

[TEST PROF INFO] Factories usage

 Total: 659
 Total top-level: 8
 Total time: 00:36.813 (out of 01:14.075)
 Total uniq factories: 11

   total   top-level     total time      time per call      top-level time      name

     103           0       32.7865s            0.3183s             0.0000s      shop
     103           0       27.1212s            0.2633s             0.0000s full_item
     103           0        0.8971s            0.0087s             0.0000s shop_user
     103           0       34.5748s            0.3357s             0.0000s item_like
...
...
...

単純に full_item の生成回数が1/3に減り、それに比例して実行時間も約1/3に圧縮できました。

改善2. テストで必要最小限なレコードのみを作成する

別の重たいテストも解析していくと、共通して同じ問題が見えてきます。

[TEST PROF INFO] Factories usage

 Total: 3157
 Total top-level: 582
 Total time: 02:05.161 (out of 04:30.214)
 Total uniq factories: 48

   total   top-level     total time      time per call      top-level time         name

     322           6        6.2221s            0.0193s             0.1149s user_profile
     276          70       74.0465s            0.2683s             5.2776s         shop
     230           0       63.5779s            0.2764s             0.0000s    full_item
...
...
...

次に着目したのは、full_item の実行時間の長さです。1回あたり 0.2764s ということでそんなに長く感じないかもしれませんが、呼び出し回数が非常に多く、塵も積もればこのテストでは230回呼ばれて 63.5779s は無視できない実行時間になっています。

またまたコードを読んでみると、full_item は関連するデータを全部盛りで作る.... というFactoryになっていました。item というモデル自体、システムの中でコアなドメインのモデルとなっており、非常に多くの関連を持ちうるものなのでした。しかし、大部分のテストにおいてはそこまで完全な状態の item は要らないという状況でした。

そこで、shop 生成時のデフォルトは full_item ではなく item というスリムなFactoryを使うように切り替え、再度解析をしてみることにしました。

[TEST PROF INFO] Factories usage


 Total: 2697
 Total top-level: 582
 Total time: 01:06.857 (out of 02:53.458)
 Total uniq factories: 47

   total   top-level     total time      time per call      top-level time         name

     322           6        4.7178s            0.0147s             0.0844s user_profile
     276          70       29.5793s            0.1072s             4.0465s         shop
     230           0       10.8307s            0.0471s             0.0000s         item

結果、1回あたり 0.2764s かかっていた full_item から、0.0471s で済む item に取り変わったことで、このテストの実行時間は半分にまで圧縮できるようになりました。

RSpecの実行時間の改善結果

他にもTestProfを用いて細かな改善は入れていき、最終的にワースト5のテスト実行時間は次のように変化しました。

spec Before After Reduce Reduce Rate
Worst1 78.37s 17.55s -60.82s -77.6%
Worst2 75.71s 15.73s -59.97s -79.22%
Worst3 48.76s 8.28s -40.48s -83.01%
Worst4 48.16s 7.84s -40.31s -83.72%
Worst5 88.01s 18.1s -69.9s -79.43%
Total of worst 339.01s 67.5s -271.51s -80.08%

339.01s から 67.5s に圧縮され、この最悪値だったテスト5本に限定して言うと、実行時間を8割以上削減できました。さらに上記のテスト以外でも今回改善したFactoryは使われているわけですから、テストの総実行時間にも効いてくるわけです。

考察

今回、TestProfを用いて、何となく重たいんだろうと思っていたところが想定以上に無駄なことをしていたことがわかりました。

「とりあえずデフォルトで全部入りのテストデータ作っておくか!」というのは、ともすればやりがちですし、歴史の長いコードベースだと気づきにくいように感じました。

「推測するな、計測せよ」という言葉がありますが、実際に計測して打ち手をピンポイントに打っていくことで、着実に改善している感覚を得られて周囲にも説明しやすかったのも学びです。

また、今回Factoryの挙動を大胆に変更することで実行時間削減を実現していますが、これは常日頃みんながテストコードをかなり手厚く書いていてくれたおかげというのが大きいです。システムが壊れないことを担保するため、不安と戦うために我々はテストを書くわけですが、テスト自体のリファクタリングにもテストコードが有効であるということは学びになりました。


ファインディは積極的にエンジニアを採用しています。CI/CDを始め、Four Keys、開発生産性、技術トレンド、転職市場など興味のある方は、お気軽にカジュアル面談を受けてみてください :)

ファインディの採用情報はこちら↓

herp.careers