Railsの規模が大きくなると自動テストの実行時間もだんだんと長くなっていきます。素早く開発していくにはテストの実行時間を短くすることが大切です。
RSpecのテストを速くする方法をまとめましたので参考にしてください。
Photo by Flickr: chief_huddleston's Photostream
目次
- 1. RSpecのパフォーマンス測定
- 2. test-queueで並列でテストを実行する
- 3. rspec-guardを使って更新したファイルを自動的にテストする
- 4. Springを使ってテストのロード時間を短くする
- 5. ログレベルを変える
- 6. GCを実行を抑える
- 7. RSpecファイルのリファクタリングをする
- 7.1.
it
を少なくする - 7.2.
create
よりもbuild_stubbed
を使う
- 7.1.
- 8. WIP その他のアイデア
1. RSpecのパフォーマンス測定
まず、RSpecの「テスト実行の総実行時間」と「どのテストが遅いテストか」を把握する必要があります。rspec
コマンドに-p
オプションを使うことで、遅いテストサンプルとグループが10件(デフォルト)表示されます。テストの総実行時間は、Finished in XXX seconds
に記載されています。
また、Top 10 slowest examples
に遅いテストサンプル、Top 10 slowest example groups
が遅いテストサンプルグループが遅い順に記載されます。
これらの、遅いテストケースを解消することを重点的にやることにより、テスト総実行時間が大幅に短くなりやすいです。
$ rspce -p ... Finished in 0.67808 seconds (files took 2.55 seconds to load) Top 10 slowest examples (0.27127 seconds, 40.0% of total time): UsersController routing routes to #show 0.05689 seconds ./spec/routing/users_routing_spec.rb:14 orders/edit renders the edit order form ... Top 10 slowest example groups: orders/edit 0.05224 seconds average (0.05224 seconds / 1 example) ./spec/views/orders/edit.html.erb_spec.rb:3 products/edit 0.02615 seconds average (0.02615 seconds / 1 example) ./spec/views/products/edit.html.erb_spec.rb:3 ...
--pforile
オプションの使い方
-p, --[no-]profile [COUNT] サンプルをプロファイルし、最も遅いサンプルを表示する(デフォルト:10件)
2. test-queueで並列でテストを実行する
test-queue
というgemを入れることで、1マシン上でCPUのコア数分だけRSpecテストを並列実行できます。もし、8コアある場合は、理論的には8分かかっていたものは1分で実行できるようになります。(並列テストの準備やローダー実行などがあるので実際の値はもう少しかかります)
では、test-queue
をRailsプロジェクトに導入していきます。
まず、Gemfileに追記します。
# Gemfile group :development, :test do gem "test-queue" end
Bundlerを実行します。
bundle install
bin/rspec-queue
という実行ファイルを作成します。
内容は次の通りです。
#!/usr/bin/env ruby ENV["RAILS_ENV"] ||= "test" require File.expand_path("../../config/environment", __FILE__) require "test_queue" require "test_queue/runner/rspec" # # テストランナー # Reference: https://github.com/tmm1/test-queue # class MyAppRSpecRunner < TestQueue::Runner::RSpec # def prepare(concurrency) # end def after_fork(num) # ワーカー別のデータベースを準備する。 ENV.update("TEST_ENV_NUMBER" => num > 1 ? num.to_s : "") ActiveRecord::Base.configurations["test"]["database"] << ENV["TEST_ENV_NUMBER"] ActiveRecord::Tasks::DatabaseTasks.create_current ActiveRecord::Base.establish_connection(:test) Rails.application.load_tasks Rake::Task["db:reset"].invoke end # def around_filter(suite) # $stats.timing("test.#{suite}.runtime") do # yield # end # end end MyAppRSpecRunner.new.execute
そして、test-queue
では、ワーカー毎にデータベースを必要とするため、データベース名に番号をつけます。
他にも、ファイル作成などテスト環境を分ける必要があるものはENV['TEST_ENV_NUMBER']
をつけます。
config/database.yml test: <<: *default database: db/test.sqlite3<%= ENV['TEST_ENV_NUMBER'] %>
bin/rspec-queue
に実行権限をつけます。
chmod +x bin/rspec-queue
そして、bin/rspec-queue
を実行します。すると、コア数分だけ並列にテストが行われます。
テスト実行後に、.test_queue_stats
というファイルが作成されますが、これは、テストの実行時間を保存しており、次回rspec-queue
を実行するときに、各ワーカーでテスト実行時間を標準化させるために使います。
$ bin/rspec-queue spec Starting test-queue master (/tmp/test_queue_97612_70155795430020.sock) ==> Summary (4 workers in 1.5298s) [ 3] 21 examples, 0 failures, 15 pending 9 suites in 1.4998s (pid 98021 exit 0) [ 1] 32 examples, 0 failures, 15 pending 5 suites in 1.5024s (pid 98019 exit 0) [ 2] 14 examples, 0 failures 6 suites in 1.5059s (pid 98020 exit 0) [ 4] 20 examples, 0 failures, 15 pending 5 suites in 1.5247s (pid 98022 exit 0)
3. rspec-guardを使って更新したファイルを自動的にテストする
毎回少しのファイルを変更するたびに全件テストを実行していたらバカになりません。(そんな人はいないと思いますが)少なくとも次のように、ファイル単位やサンプル単位で実行していると思います。
# ファイル単位でrspecを実行する $ bin/rspec spec/features/users_spec.rb # 行指定(120行目)にマッチしたrspecのテストを実行する $ bin/rspec spec/features/users_spec.rb:120
これを、ファイルを変更するたびに、何度もコマンドを実行するのはめんどくさいので、rspec-guard
を使うことで自動的にテストをするようにできます。
副次的な効果として、ファイルを保存するたびに、rspec-guard
により細かくテストをしてくれるので、どこでテストがこけたかがすぐに分かるのでどのコードがいけないのかすぐにわかります。
4. Springを使ってテストのロード時間を短くする
Railsが大規模になるほど、依存ライブラリやファイル数が増えるのでrspecコマンド実行時のロード時間が長くなる。プリローダーのSpringを使うことで、ロード時間を短くすることができます。
10秒が3秒ぐらいになるなどそこまで効果はないですが、何回もコマンドを実行すると思えば、チリも積もれば山となるということで入れておくのをお勧めします。
5. ログレベルを変える
development
環境とtest
環境では、デフォルトのログレベルが:debug
になっています。test
環境では、ログを確認するケースは少ないので、ログレベルを:error
に設定し、I/O出力を減らします。
# config/environments/test.rb # Setting of a Log Level config.log_level = :error
6. GCを実行を抑える
RSpec実行中のGCの実行を抑えることで、テスト時間を早くさせるハックです。Rubyのバージョンも上がり、GCの性能が良くなってきたので、むしろ遅くなる可能性もありますのでご注意ください。
GCを実行するクラスのファイルを作成します。
# spec/supports/deferred_garbage_collection.rb # http://ariejan.net/2011/09/24/rspec-speed-up-by-tweaking-ruby-garbage-collection # # Usage: # DEFER_GC=10 rspec spec/ # DEFER_GC=10 cucumber features/ # # put it to spec/support/deferred_garbage_collection_all_in_one.rb # or feature/support/hooks.rb class DeferredGarbageCollection DEFERRED_GC_THRESHOLD = (ENV['DEFER_GC'] || -1).to_f @last_gc_run = Time.now def self.start GC.disable if DEFERRED_GC_THRESHOLD > 0 end def self.reconsider if DEFERRED_GC_THRESHOLD > 0 && Time.now - @last_gc_run >= DEFERRED_GC_THRESHOLD GC.enable GC.start GC.disable @last_gc_run = Time.now end end end
そして、specの設定ファイルに下記を追加します。
# spec/rails_helper.rb RSpec.configure do |config| config.before(:all) do DeferredGarbageCollection.start end config.after(:all) do DeferredGarbageCollection.reconsider end end
7. RSpecファイルのリファクタリングをする
7.1. it
を少なくする
テストの基本として、1テストあたりで確認することは1つという考えがあります。こうすることで、失敗したテストから、ソースコードの悪い箇所を特定しやすいためです。しかし、1テスト(
it
)を多くすると、テスト実行時間という観点からはテストの前準備処理が何度も実行されるので、テストの実行時間が長くなります。そのため、「テストの可読性/保守性」と「テストの実行時間」というトレードオフをうまく考えながら
it
をまとめていく必要があります。個人的な考えとしては次のようなように考えています。
- モデルやコントローラーの単体テストでは、前準備の処理も軽く、ここの事象を確認するので
it
は細くて良い - Featuresなどのエンドツーエンドテストでは、前準備の処理も時間がかかるので、可読性と保守性を損ねない程度に
it
をまとめる
次は、it
をまとめる前後のサンプルのRSpecのテストコードです。
# 商品管理のFeatures 修正前 describe "商品管理" do describe "商品を登録する" do before do visit products_path click_link "新規商品を登録" end it "新規商品登録画面が表示されること" do within(:h1) { expect(page).to have_content "新規商品登録" } end context "有効な商品情報を入力した場合" do # itのたびにこのbeforeブロックが繰り返される before do fill_in "商品名", with: "パソコン" fill_in "値段", with: 100_000 click_button "登録する" end it "商品詳細画面が表示されること" do within("h1") { expect(page).to have_content "商品詳細" } end it "登録した商品情報が表示されること" do within("#product") do expect(page).to have_content "パソコン" expect(page).to have_content "100,000" end end it "登録した商品情報がDBに登録されていること" do result = Product.last expect(result.name).to have_content "パソコン" expect(result.price).to have_content 100_000 end end context "無効な商品情報を入力した場合" do # Do something ... end end end
極力可能なものはit
をまとめます。見易さという点では修正前の方がよいですが、テスト実行時間は減ります。
# 商品管理のFeatures 修正後 describe "商品管理", type: :feature do describe "商品を登録する" do before do visit products_path click_link "新規商品を登録" end it "新規商品登録画面が表示されること" do within(:h1) { expect(page).to have_content "新規商品登録" } end context "有効な商品情報を入力した場合" do # itの数が3から1に減ったので、このbeforeブロックの実行回数も3から1へ減る # すなわち、テスト実行時間も減る before do fill_in "商品名", with: "パソコン" fill_in "値段", with: 100_000 click_button "登録する" end it "登録した商品情報が登録されること" do # 商品詳細画面が表示されること within("h1") { expect(page).to have_content "商品詳細" } # 登録した商品情報が表示されること within("#product") do expect(page).to have_content "パソコン" expect(page).to have_content "100,000" end # 登録した商品情報がDBに登録されていること result = Product.last expect(result.name).to have_content "パソコン" expect(result.price).to have_content 100_000 end end context "無効な商品情報を入力した場合" do # Do something ... end end end
7.2. create
よりもbuild_stubbed
を使う
テスト実行時間の大部分を占めているものの1つにデータをDBに登録するという処理があります。これは、ディスクへのI/O書き込みが発生するので、メモリに比べて遅くなるのはいたしかたありません。
そのため、主にモデルのテストで、FactoryGirl
のcreate
でデータを作成するよりも、build_stubbed
でスタブを作成してテストを実行させることにより、DBへの書き込みを少なくさせてテストを早くするという方法です。
let
内でcreate
メソッドを使ってテストデータを毎回作成しています。
# 注文明細(line_item)のモデルSpecファイル 修正前 RSpec.describe LineItem, :type => :model do describe "#total_price" do let(:product) { create(:product, price: 100)} let(:line_item) { create(:line_item, product: product, quantity: quantity) } subject { line_item.total_price } context "quantity = 0" do let(:quantity) { 0 } it { is_expected.to eq 0 } end context "quantity = 1" do let(:quantity) { 1 } it { is_expected.to eq 100 } end context "quantity = 2" do let(:quantity) { 2 } it { is_expected.to eq 200 } end end end
変更は簡単でbuild_stubbed
に変更するだけです。
DBにデータを書き込まないので、DBからSELECTでデータを取得する処理では使えません。
# 注文明細(line_item)のモデルSpecファイル 修正後 RSpec.describe LineItem, :type => :model do describe "#total_price" do let(:product) { build_stubbed(:product, price: 100)} let(:line_item) { build_stubbed(:line_item, product: product, quantity: quantity) } ... end end
WIP その他のアイデア
- SOA的な感じでシステムを分けてテストを少なくさせる