production.log

株式会社リブセンスでエンジニアをやっている星直史のブログです。

CircleCIで回しているRspecのテストを40%高速化しました

タイトルの通り、CirclCIで回しているテストを40%高速化した話をします。

うちの会社では、342files, 27300examples強を回しており、テスト時間が肥大化傾向にありました。
そこで、テスト高速化を図ろうと試行錯誤したので、その過程を書きます。
RRRSpec使えよ!というツッコミはなしで。CircleCI上で試行錯誤の記録を残すために書きます。

また、spec自体の高速化ではなく、CircleCIの仕様に合わせた高速化の方法についてのみを書きます。

やり方

なんと、この2つだけです!
シンプル!なんてシンプル!
並列実行して、遅いテストを特定するだけです。
そもそも技術力なんていりません。気合と根性*1で速くできます。

並列実行する

並列数を変更

CircleCIで並列実行数を増やすオプションがあります。

Project Settings => Tweaks => Adjust Parallelism
から設定ができます。

f:id:watasihasitujidesu:20150924222541p:plain

並列数増やせば(金で解決すれば)高速化できるんじゃね?っと、頭がよぎりましたが、
今回の話は、並列数は据え置きのまま(6並列)という縛りプレイでいきます。

もちろん、並列数は多いほど高速になります。

並列で実行するときに理解しておきたいこと

並列で実行する際、CircleCI上では複数コンテナを使ってテストを実行します。
理解しておきたいのは、複数コンテナ上実行されるテストはファイル単位となることです。
example単位ではないので、注意が必要です。

また、並列化した場合、テスト完了は、最も遅く終わったコンテナに引っ張られます。

  • コンテナA: 10分
  • コンテナB: 20分

この場合、CircleCI上では、テスト完了時間が20分になります。
要は、1コンテナあたりの実行時間限りなく、全コンテナの平均時間に近づけたいのです。

で、分散させる方法は下記の通りです。
1. まずはcircle.ymlを修正

この設定では、テスト実行を上書きしており、parallel: true (並列実行モード)にしています。

test:
  override:
    - ./script/parallel_for_circle.sh:
        parallel: true

では、parallel_for_circle.shを見ていきましょう。
2. 各コンテナで実行するテストは指定する方法

#!/bin/bash
set -xe

i=0
files=()
for file in $(find ./spec -name "*_spec.rb" | sort)
do
  if [ $(($i % $CIRCLE_NODE_TOTAL)) -eq $CIRCLE_NODE_INDEX ];then
    files+=" $file"
  fi  
  i=$((i+1))
done

echo $files
bundle exec parallel_rspec ${files[@]} -n 2
exit $?

CircleCIでは、並列実行時、各コンテナにCIRCLE_NODE_INDEXという、何個目のコンテナか?というインデックスが振られます。
また、CIRCLE_NODE_TOTAL全部で何並列か?という数値も取れます。
CircleCIではテストコマンドを上書きすることができるので、1ファイルごとにループを回し、
どのコンテナに割り当てるかをゴリゴリ書いていけば、均等に割り当てることができます。

もちろん、*1_spec.rbなどとすれば、コンテナを指定することができます。
が、全実行時にコンテナを指定するメリットがあまりないので、今回は上記のスクリプトで並列化します。*2

遅いテストファイルを特定する

次のステップは遅いファイルを特定して、そのファイルを分割することです。
6並列でテストを回すとこのような結果になります。
f:id:watasihasitujidesu:20150924224914p:plain f:id:watasihasitujidesu:20150924224935p:plain f:id:watasihasitujidesu:20150924224952p:plain f:id:watasihasitujidesu:20150924225039p:plain f:id:watasihasitujidesu:20150924225051p:plain f:id:watasihasitujidesu:20150924225103p:plain

見にくくて申し訳ないですが、実行時間は
- 1台目:14:03
- 2台目:15:55
- 3台目:16:29
- 4台目:38:46
- 5台目:13:13
- 6台目:12:06
合計96分。平均16分で終わるはずなので、それ以上かかっているコンテナに遅いファイルが存在する可能性が高いです。
この中ではダントツで4台目が遅いので、4台目に時間がかかっているテストファイルが存在するだろうと推測できます。

次に、並列数はそのままで、4台目の実行されたテストファイルを実行していきます。
さきほどのスクリプトではecho $filesと書いているので、CircleCI上のダッシュボードから実行されているテストファイルが確認できますのでメモっておきましょう。

メモっておいたテストファイルの絞り込みは、単純に

rm -rf ./spec/*
cat output_file | awk '{print "git checkout "$1""}'
....

などとしていけば4台目で実行されたものだけが残り、
実行することができます*3
理論的には6分ほどで終わるでしょう。

これを繰り返していけば、実行時間が長いファイルが特定できます。

遅いテストファイルを分割する

遅いファイルが特定できたら、あとはファイルを分割するだけです。
おそらく遅いファイルでは複数describeブロックやcotextブロックが存在すると思います。

# coding: utf-8
require 'spec_helper'

describe 'Hoge.fuga?' do
  let(:hoge) { FactoryGirl.create(:hoge)}
  
  subject{ hoge.fuga? }

  it{should be_true}
end

describe 'Hoge.fugafuga?' do
  let(:hoge) { FactoryGirl.create(:hoge)}
  
  subject{ hoge.fugafuga? }

  it{should be_false}
end

例えば、このテストファイルが実行完了まで10分かかっていたとします。
このファイルをhogehoge_fuga_rspec.rbとhogehoge_fugafuga_rspec.rbの2ファイルに分割した場合

# coding: utf-8
require 'spec_helper'

describe 'Hoge.fuga?' do
  let(:hoge) { FactoryGirl.create(:hoge)}
  
  subject{ hoge.fuga? }

  it{should be_true}
end
# coding: utf-8
require 'spec_helper'

describe 'Hoge.fugafuga?' do
  let(:hoge) { FactoryGirl.create(:hoge)}
  
  subject{ hoge.fugafuga? }

  it{should be_false}
end

並列で実行すると、最高で5分に短縮できます。

このような泥臭いことを続けていきます。
ファイルを特定 => ファイル分割 => ファイルを特定 => ファイル分割 => ....................

ただし、分割するのはいいのだけれど、間違った分割の仕方をしたら、死罪に値するかもしれません。
たとえば、
hoge_controller_get_action_spec.rb hoge_controller_post_action_spec.rb とかだったら中身を見なくとも、どのようなテストが書かれているか予測ができると思うのですが、

hoge_controller_1_spec.rb hoge_controller_2_spec.rb

などとした場合、いちいちファイルを開かねばならず、苦労しそうですので気をつけましょう。

まとめ

テクニカルなことは一つもやっていないのですが、簡単なことでテスト時間を40%も高速化することができました。*4

また、CircleCIの挙動に合わせた高速化ではなく、spec自体の高速化は、
下記のエントリが参考になりますので、お試しください。

ruby-rails.hatenadiary.com

*1:うちの会社の裏行動指針です

*2:もっと簡単な方法もあります。Qiitaで探してみてください

*3:かならず違うブランチでやりましょう

*4:これを自動化すれば良いって話ですが