Rails Webook

毎週更新。Railsプログラマがソフトウェア開発を効果的・効率的にするために、分かりやすく・使いやすい形でRailsに関する技術情報を記載!

RailsのRSpecテストを速くする方法まとめ

Railsの規模が大きくなると自動テストの実行時間もだんだんと長くなっていきます。素早く開発していくにはテストの実行時間を短くすることが大切です。
RSpecのテストを速くする方法をまとめましたので参考にしてください。

f:id:nipe880324:20150106210347j:plain:w480
Photo by Flickr: chief_huddleston's Photostream

動作確認

目次

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-queueRailsプロジェクトに導入していきます。
まず、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秒ぐらいになるなどそこまで効果はないですが、何回もコマンドを実行すると思えば、チリも積もれば山となるということで入れておくのをお勧めします。

  • Springの導入方法、RSpecコマンド + Springを使う方法については、コチラ


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書き込みが発生するので、メモリに比べて遅くなるのはいたしかたありません。

そのため、主にモデルのテストで、FactoryGirlcreateでデータを作成するよりも、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的な感じでシステムを分けてテストを少なくさせる