Railsプロダクトにparallel_testsを導入してCIの実行時間を減らしたときの知見

プレセナ・ストラテジック・パートナーズ AdventCalendar2025 19日目の記事です。

qiita.com


こんにちは、プレセナのエンジニアの田中です。

弊社のRailsプロダクトではテストフレームワークに rspec を採用しています。 また、テストコードはGitHub Actions上で自動実行するようにしています。

プロダクトを開発する中で出てきた課題感として、「CIの実行完了まで時間がかかっており、待ち時間が発生する」という課題がありました。 この課題の対策として、CI上でのテストで parallel_tests を導入しました。

その結果20分以上かかっていたCIの実行時間を 8〜9分程度まで短縮できました。

導入時に考えたことや工夫について記載します。

前提

施策実施前のCI実行時間は以下です。

施策実施前のCIのパフォーマンスアベレージ

平均して20分以上かかっていました。

スクショだと2つワークフローがありますが、今回は spec 側の速度改善を対象としています。 参考までに各ワークフローの役割は以下のようになっています。

  • spec
    • PR作成時などに動作するワークフロー
  • cache_develolpment
    • bundle install などの結果をキャッシュし、 spec 側の前準備の工程に効かせるワークフロー
    • 週次で動作する

やったこと

前述の cache_develolpment によって、キャッシュがあればbundle installはスキップするなど、キャッシュ戦略はすでに実施済です。 他に改善の余地がないか考えたところ、 テストを直列から並列で実行することで改善が図れそうでした。

rspecの並列実行は parallel_tests というGemがあったためこちらの導入を検討しました。 また、並列数を上げるためにrunnerのスペックを上げることも検討しました。

parallel_tests

github.com

parallel_testsはrspec等のランナーを並列で動作させるためのGemです。

例えば2coreのCPUのマシンの場合2並列でテストが動作し、16coreの場合16並列で動作します。

各プロセスへのテストの振り分けなどもparallel_testsが自動でやってくれます。 また、各プロセス間でのテスト実行時間にばらつきが出ないように、前回のテストの実行時間をベースに調整する、といったこともやってくれます。

parallel_testsを導入した YAML ファイル

parallel_testsを導入し、 以下のような YAML ファイルになりました(重要な部分のみ抜粋)

name: PR Spec

on:
  pull_request:
jobs:
  spec:
    runs-on: ubuntu-latest-16core
    env:
      TZ: Asia/Tokyo
    services:
      mysql:
         # 省略
    steps:
      - name: Create parallel test databases
        run: |
          # MySQL databases (16個作成)
          for i in {2..16}; do
            mysql --protocol=tcp --host 127.0.0.1 --user=root --password=root_password -e "CREATE DATABASE IF NOT EXISTS test${i};"
          done

      - name: Migrate databases (Initial setup)
        run: |
          bundle exec rails db:reset RAILS_ENV=test
          bundle exec rails db:migrate RAILS_ENV=test

      - name: Migrate databases
        run: |
          # 並列テスト用のデータベースをセットアップ(16並列)
          RAILS_ENV=test bundle exec rake parallel:prepare
          
          # 各並列DBに対してcassata_dbのマイグレーションとseedを並列実行
          for i in "" {2..16}; do
            (
              TEST_ENV_NUMBER=$i bundle exec rails db:seed RAILS_ENV=test &&
              TEST_ENV_NUMBER=$i bundle exec rails db:seed_fu RAILS_ENV=test
            ) &
          done
          wait
      - name: Run RSpec tests in parallel
        run: |
          bundle exec parallel_rspec spec/ -n 16 \
            --test-options='-f j -o tmp/rspec_results-$TEST_ENV_NUMBER.json -f p'

      - name: RSpec Report
        uses: SonicGarden/rspec-report-action@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          json-path: tmp/rspec_results-*.json
        if: always()

どうなったか

結果的にCIの時間を10分以下まで短縮できました。

施策実施後のパフォーマンスアベレージ

compute minutes 消費量の変化

GitHub Actionsのcompute minutes(買い切りのCIの実行可能時間)の消費量の変化をざっくり計算すると、

  • 20→10分なので実行時間は0.5倍
  • 2core→16coreのマシンに変更しているため、GAの消費時間の係数が8倍
  • 8 * 0.5 でトータル4倍

compute minutes 消費量4倍で実行時間半分なら、まぁペイできるかな・・・?ぐらいの感覚ではあります。

導入時の工夫

導入時に工夫したことです。

並列数分のデータベースを用意する

parallel_tests は各プロセスで競合させないために、各種データベースなど、テストプロセス間で分離する必要があります。

基本的にはREADMEに書いてあるとおりですが、database.ymlに以下のように書いて、rake タスクを実行するとよしなにやってくれます。

test:
  database: yourproject_test<%= ENV['TEST_ENV_NUMBER'] %>

弊社のプロダクトではseedやseed_fuなど事前のfixture準備が必要だったため、 parallel_tests側の rake タスクに加えて seed 実行も行っています。

ここの for も並列で動作するように書いています。

      - name: Migrate databases
        run: |
          # 並列テスト用のデータベースをセットアップ(16並列)
          RAILS_ENV=test bundle exec rake parallel:prepare
          
          # 各DBに対してseedを並列実行
          for i in "" {2..16}; do
            (
              TEST_ENV_NUMBER=$i bundle exec rails db:seed RAILS_ENV=test &&
              TEST_ENV_NUMBER=$i bundle exec rails db:seed_fu RAILS_ENV=test
            ) &
          done
          wait

並列数分のテストレポートをまとめる

本プロダクトではテストレポートの生成に rspec-report-action というGitHub Actionを使っています。

github.com

rspec-report-actionではレポート作成のもととなるjsonを複数入力しレポート作成する機能があったため、並列数分の実行結果jsonを作成しrspec-report-actionに渡すようにしています。

      - name: Run RSpec tests in parallel
        run: |
          bundle exec parallel_rspec spec/ -n 16 \
            --test-options='-f j -o tmp/rspec_results-$TEST_ENV_NUMBER.json -f p'
            # --test-options内の `$TEST_ENV_NUMBER` は parallel_tests 側で評価しrspecに渡される
            # tmp/rspec_results-1, 2, 3,... .json のようなファイルが作られる

      - name: RSpec Report
        uses: SonicGarden/rspec-report-action@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          # v6からjsonのpathをglobで複数指定できる
          json-path: tmp/rspec_results-*.json
        if: always()

導入後の副次的な効果

以下並列実行したことによって起きた副次的な作用です。

flakyなテストが顕在化した

テストを並列実行したことによって、以前に増してflakyなテスト(不安定で時々失敗するテスト)が顕在化するようになりました。 例えば、以下のようなものです。

  • あるテストの前準備に依存していたテスト
  • fixture作成時にid指定しており実行順序によっては落ちるテスト

基本的には愚直に潰すしかないので、出てきたベースで対応しています(今でもたまに落ちたりします。。)

まとめ

parallel_tests を導入し、CIの実行時間短縮をしてみました。

やはりソフトウェア開発において速さは正義だなと体感します。 特にテストやビルドの待ちの短さは開発者体験に大きく効いてきますね。 こういうポイントにお金をかけてもいい組織は良い組織だなと感じます。

各プロセスでのテスト実行時間平準化など、まだまだ改善の余地があると思いますし、またアプリケーションコードの増加とともに線形で増えてしまうものなので、今後も改善を続けていきたいですね。

参考

GitHub - grosser/parallel_tests: Ruby: 2 CPUs = 2x Testing Speed for RSpec, Test::Unit and Cucumber

Qiita で parallel_tests を後から入れた時にやった工夫 #Ruby - Qiita

2分台で1500examples完走!爆速CIを支える環境構築術 / Hayato OKUMOTO | Kaigi on Rails 2025