ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

テストコード(RSpec)の高速化事例

はじめまして、ユニファ株式会社でWebエンジニアとして働いている本間と申します。 普段は、Ruby on Railsを使ったサービスのサーバーサイドの機能追加や改善を担当しています。

弊社の主要サービスである写真サービス るくみーは2013年5月に開発がスタートし、あと少しで5年目を迎えようとしています。 開発がこれだけの期間続けられているというのはとても喜ばしいことだと思うのですが、一方で開発初期にはなかった問題も発生してきています。 そのような問題の1つに「テストコードの実行完了までに時間がかかる」というものがあり、弊社でも発生しておりました。

今回、この問題に対して改善施策を実施し、それなりの効果を得ることができましたのでご紹介したいと思います。

※注意 : RSpecは2系、かつDatabaseCleanerを使用していることが前提となる箇所があります。

結果比較

はじめに改善施策の実施前後の結果の比較です。 以下は、私のローカル環境(MacBook Pro (Retina 13-inch、Early 2015))で全てのテストコードを走らせた時の実行時間の比較です。

実施前(17:02)

f:id:ryu39:20170123113700p:plain

実施後(3:16)

f:id:ryu39:20170124111411p:plain

改善対象の洗い出し

性能改善を始めるにあたって、最初に攻めどころを明確化を行いました。

RSpecはデフォルトで時間がかかるexampleやspecファイルを表示してくれますが、今回の用途に少しマッチしなかったため「合計実行時間」が大きい順にspecファイルを表示してくれるFormatterを自作して対応しました。作成したFormatterは扱いやすいようにGemにしてあります(※注意 RSpec 2系でしか動きません)。

このFomatterを使ってRSpecのテストを実行すると、下記のように合計実行時間の上位10specファイルが表示されます(クラス名とパスは隠しています)。

f:id:ryu39:20170124112446p:plain

改善施策の実施

合計実行時間が長い順にspecファイルを調べ、原因と思われる箇所に対して対策を実施していきました。

不要な機能のテストコードを削除

実行時間がかかる上位のspecの中に、今後使う予定がないrakeタスクのテストコードがいくつかありました。 このコードは不要でしたので本体コードごと削除しました。

権限チェックテストの簡略化

弊社サービスには何種類かのロールがあり、ロールによって操作可能なアクションが異なります。

時間のかかるControllerのテストでは、各アクションに対して全てのロールで成功 or エラーのテストを実施していました。具体的には、以下のようなコードになります。

describe UsersController do
  describe '#index' do
    let!(:users) { create_list(:user, 10) } # テストデータ作成

    subject(:response) { get :index }

    # 全てのロールで成功 or エラーをテスト
    UsersController::PERMITTED_ROLE_IDS.each do |role_id|
      before do
        session[:role_id] = role_id
      end

      it { 
        expect(response.status).to eq(200)
        expect(assigns(:users)).to eq(users)
      }
    end
    (Role::ALL_ROLE_IDS - UsersController::PERMITTED_ROLE_IDS).each do |role_id|
      before do
        session[:role_id] = role_id
      end

      it { expect(response.status).to eq(403) }
    end
  end
end

ロールによる権限チェックのテストは重要なのですが、「テストデータ作成」と「アクション処理」が複数回実施されていて、ここに改善の余地があると考えました。

解決策としては、機能的なテストは1つの代表ロールでのみチェックし、ロールの権限チェックテストではAnonymous controllerを使用することで「テストデータの作成」と「成功時のアクション処理」をスキップするようにしました。

Anonymous controller はRSpecが提供する機能の一つで、テスト対象のControllerを継承したクラスをテストコード内で定義することができます。Anonymous contollerでアクションの中身をオーバーライドして空のレスポンスを返すことで、before_action で実施している権限チェックのコードをテストしつつ、アクション処理の実行時間を短縮できます。

修正後は以下のようなコードになりました。

describe UsersController do
  before do
    session[:role_id] = 9999 # 代表ロールID
  end

  # 機能的なテストは1つの代表ロールでのみ確認
  describe '#index' do
    let!(:users) { create_list(:user, 10) } # データ準備

    subject(:response) { get :index }

    it { 
      expect(response.status).to eq(200)
      expect(assigns(:users)).to eq(users)
    }
  end

  describe '権限チェック' do
    controller do
      # 空レスポンスを返すだけ、成功時のアクション処理時間を短縮
      def index
        head :ok
      end
    end
    before do
      routes.draw do
        resources :users, controller: :anonymous, only: [:index]
      end
    end

    describe '#index' do
      # 権限チェックではテストデータを作成しないことで時間短縮

      subject(:response) { get :index }

      UsersController::PERMITTED_ROLE_IDS.each do |role_id|
        before do
          session[:role_id] = role_id
        end

        it { expect(response.status).to eq(200) }
      end
      (Role::ALL_ROLE_IDS - UsersController::PERMITTED_ROLE_IDS).each do |role_id|
        before do
          session[:role_id] = role_id
        end

        it { expect(response.status).to eq(403) }
      end
    end
  end
end

共通データの事前作成

時間のかかるspecの多くでは、DBへのデータ書き込み(特にどのテストでも使う共通のオブジェクトの作成)で時間がかかっていました。

この問題を解決する方法として、specファイル内で共有可能なデータは before(:all) で事前に作成し、これを各exampleで参照するようにしました。

しかし、この方法はテスト間の独立性を壊してしまう可能性があるため利用には注意を払う必要があります。今回、以下のルールに沿って実施しました。

  • 共有するのは参照専用のオブジェクトのみ。
  • 共有する範囲は1つのspecファイル内まで。
  • 対象は合計実行時間の上位20specファイルまで。
  • 必ず after(:all) で作成したオブジェクトを全て削除する。

テストコードは以下のようになります。

describe SomeController do
  before(:all) do
    @user = create(:user)
  end
  after(:all) do
    DatabaseCleaner.clean_with(:truncation)
  end

  # 以降、@userでユーザーオブジェクトを参照
end

rspecの並列実行

上記施策を実施して実行時間を10分前後に減らすことはできたのですが、大きな改善を見込める箇所が見当たらなくなってしまいました。 そこで、最終手段としていたテストの並列実行を導入しました。

RSpecを並列実行するGemとしてparallel_teststest-queueが有名なようです。 今回は「parallel_tests」を使用しました。導入にあたって実施した内容は以下になります。

  1. Gemfileに parallel_tests を追加し、 bundle install
  2. database.ymlを開き、テスト用のデータベース名の末尾に <%= ENV['TEST_ENV_NUMBER'] %> をつけるように修正。
  3. bundle exec rake parallel:setup を実行し、テスト用のデータベースを準備する。
  4. parallel_rspec spec でテストを実行する。

並列化の効果は非常に大きく、これだけでテストの実行時間が3分の1程度になりました。

parallel_testsを使う場合、以下の設定をしておくとオススメです。

  • 各specの実行時間を記録したログを出力しておく。公式リポジトリに説明があります。この設定を行うと前回の実行時間に基づいてspecファイルが分配されるため、プロセスごとの実行時間のばらつきが小さくなり、早く全てのテストが終わる。
  • parallel_rspecを実行する際に --nice を指定すると、テスト実行中に他の作業をするとき動作がもっさりしにくくなる。

実施しなかった対策

下記の施策は検討には上がっていたのですが、実施はしませんでした。 本体への影響が大きかったり、実施に時間がかかるというのが主な理由です。

  • インメモリDBを使う
  • RubyやRailsのバージョンアップ
  • テストの精査・サンプリング

Werckerでも並列実行

弊社ではCIサービスのWerckerを利用しており、リポジトリへのpushを検知して自動でテストを流すようにしています。 こちらも並列化により実行時間を大幅に短縮できたので、合わせてご紹介いたします。

設定は非常に簡単で wercker.yml の記載を以下のように変更するだけでした。

build:
    steps:
        - bundle-install

        # rails-database-ymlが生成するdababase.ymlにはDB名に環境変数「TEST_ENV_NUMBER」が組み込まれている。
        - rails-database-yml

        # db:schema:load の代わりに parallel:setup を、 rspecコマンドの代わりにparallel_rspecコマンドを使うだけ。
        - script:
            name: Set up db
            code: bundle exec rake parallel:setup
        - script:
            name: rspec
            code: parallel_rspec spec

まとめ

今回、弊社で実施したRSpecのテストコードの高速化事例をご紹介させていただきました。

この活動はまだまだ終わりではなく、継続して改善していく必要があります(テストコードはどんどん増えていくため...)。 今後、大きな改善をすることができたら、またこのブログで紹介したいと考えています。

それでは最後まで読んでいただきありがとうございました。