🙆

GitHub Actions上でparallel_testsを使いテストを並列実行する

2023/03/16に公開

こんにちは、M-Yamashitaです。

今回の記事は、parallel_testsを使ったテストの並列実行の話です。
執筆のきっかけとして、私が出会ったRailsのリポジトリにて、テストが多くCI完了までの時間が長いことに悩んでいました。一部のテストが遅いということは分かっていましたが、改善には時間がかかるようでした。
そのためCIを高速化するために、改善を進めつつ、別の切り口として何かないかと探していたところ、parallel_testsを見つけました。
このparallel_testsの理解を深めつつ、分かったことを記事として残したいと思い、執筆しました。

この記事で伝えたいこと

  • parallel_testsとは何か?
  • GitHub Actionsでのparallel_testsの使用

parallel_testsとは

概要

リポジトリから引用します。

https://github.com/grosser/parallel_tests

Speedup Test::Unit + RSpec + Cucumber + Spinach by running parallel on multiple CPU cores.
ParallelTests splits tests into even groups (by number of lines or runtime) and runs each group in a single process with its own database.

このgemでは複数のCPUコアを使って、RSpecなどを高速化できます。parallel_testsではテストをグループ化し、それぞれのプロセスにおいて、プロセスごとのデータベースを使いテストします。

例えば、使用可能なCPUコア数が4つであれば、parallel_testsで4つのデータベースを作り、それぞれそのデータベースを使ってテストを実行することになります。

並列化の仕組み

parallel_testsでは、テストの並列実行のために、以下2つを行なっています。

  • CPUコア数を取得
  • 各コアでテストを実行

この2つについて、コード上でどんなことをしているのか説明します。

CPUコア数を取得

CPUコア数は以下メソッドで取得しています。

parallel_tests.rb
def determine_number_of_processes(count)
  [
    count,
    ENV["PARALLEL_TEST_PROCESSORS"],
    Parallel.processor_count
  ].detect { |c| !c.to_s.strip.empty? }.to_i
end

https://github.com/grosser/parallel_tests/blob/faa9a56f46782798da79edc8f2089eadcd5bb5f8/lib/parallel_tests.rb#L16

countENV["PARALLEL_TEST_PROCESSORS"]Parallel.processor_countの順に見ていき、値があればその値をCPUコア数とするようです。
なおcountは、parallel_tests実行時のオプション(-n [PROCESSES])を指定した場合、値がセットされます。セットされていない場合nilとなります。
またENV["PARALLEL_TEST_PROCESSORS"]は、parallel_tests実行時に環境変数がセットされていれば、その値がセットされます。こちらも、セットされていなければnilとなります。
オプションや環境変数をしてしていなかった場合、上記2つはnilとなるので、最後のParallelモジュールのprocessor_countを見てみます。

processor_count.rb
def processor_count
  require 'etc'
  @processor_count ||= Integer(ENV['PARALLEL_PROCESSOR_COUNT'] || Etc.nprocessors)
end

https://github.com/grosser/parallel/blob/383a960aa275e0ab778f76167b2be89dc5274a2b/lib/parallel/processor_count.rb#L6

環境変数(PARALLEL_PROCESSOR_COUNT)があればその値を、なければEtc.nprocessorsを呼び出しコア数とする単純なメソッドです。Etcモジュールはrubyリポジトリが管理しているので、そのメソッドのドキュメントを確認すると、CPUコア数を
返すとあります。

有効な CPU コア数を返します。

require 'etc'
p Etc.nprocessors #=> 4

https://docs.ruby-lang.org/ja/latest/method/Etc/m/nprocessors.html

以上より、parallel_testsは、環境変数もしくはEtc.nprocessorsを使ってコア数を取得していることが分かります。

それぞれのコアでテストを実行

コア数を取得できたので、次は並列実行の仕組みです。
並列実行は、以下メソッドで実行しています。

cli.rb
def run_tests_in_parallel(num_processes, options)
  test_results = nil

  run_tests_proc = -> do
    groups = @runner.tests_in_groups(options[:files], num_processes, options)
    groups.reject!(&:empty?)

    if options[:only_group]
      groups = options[:only_group].map { |i| groups[i - 1] }.compact
      num_processes = 1
    end

    report_number_of_tests(groups) unless options[:quiet]
    test_results = execute_in_parallel(groups, groups.size, options) do |group|
      run_tests(group, groups.index(group), num_processes, options)
    end
    report_results(test_results, options) unless options[:quiet]
  end

  if options[:quiet]
    run_tests_proc.call
  else
    report_time_taken(&run_tests_proc)
  end

  if any_test_failed?(test_results)
    warn final_fail_message

    # return the highest exit status to allow sub-processes to send things other than 1
    exit_status = if options[:highest_exit_status]
      test_results.map { |data| data.fetch(:exit_status) }.max
    else
      1
    end

    exit exit_status
  end
end

https://github.com/grosser/parallel_tests/blob/faa9a56f46782798da79edc8f2089eadcd5bb5f8/lib/parallel_tests/cli.rb#L71

このなかで、実際に並列実行のテストをしているのは、以下のコードとなります。

cli.rb
    test_results = execute_in_parallel(groups, groups.size, options) do |group|
      run_tests(group, groups.index(group), num_processes, options)
    end

ここでgroupsは、テストのファイルサイズごとでグルーピングしたテストファイル一覧となります。そのため、execute_in_parallelメソッドで、CPUコアごとのテストファイル一覧をそれぞれ取り出し、run_testsメソッドでテスト実行となります。

execute_in_parallelメソッドでどうやってテストファイル一覧を取り出しているのか、見てみます。

cli.rb
def execute_in_parallel(items, num_processes, options)
  Tempfile.open 'parallel_tests-lock' do |lock|
    ParallelTests.with_pid_file do
      simulate_output_for_ci options[:serialize_stdout] do
        Parallel.map(items, in_threads: num_processes) do |item|
          result = yield(item)
          reprint_output(result, lock.path) if options[:serialize_stdout]
          ParallelTests.stop_all_processes if options[:fail_fast] && result[:exit_status] != 0
          result
        end
      end
    end
  end
end

https://github.com/grosser/parallel_tests/blob/faa9a56f46782798da79edc8f2089eadcd5bb5f8/lib/parallel_tests/cli.rb#L56

ここで重要なのは、Parallel.mapです。Parallelモジュールを使うことで、複数のコアを使ってコードを実行できます。
https://github.com/grosser/parallel

よってParallel.mapメソッドにより、コアごとに行なうテスト一覧をitemとして取り出し、yield(item)に渡して実行させています。

以上が並列実行の仕組みとなります。

GitHub Actions上で使う

環境

Ruby on Rails: 7.0.4.2
MySQL: 5.7
parallel_tests: 4.2.0
テストファイル数: 16ファイル
テスト数: 54個

gemの導入と設定の追加

parallel_testsのREADMEをもとに、Gemfile、database.yml、.rspec_parallelを修正します。

Gemfile

READMEと同じく、developmentとtest環境でgemを使えるようにします。

Gemfile
group :development, :test do
  gem 'parallel_tests'
end

database.yml

ここでは、環境変数をdb名の後に追加します。parallel_testsのREADMEでは以下のように説明されています。

Add to config/database.yml
ParallelTests uses 1 database per test-process.

Process number 1 2 3
ENV['TEST_ENV_NUMBER'] '' '2' '3'

つまり、この環境変数を使うことで、parallel_testsはCPUコア数に応じて、parallel_tests_db_test、parallel_tests_db_test2、parallel_tests_db_test3のようにDBを作成できるようになります。

database.yml
test:
  <<: *default
  database: parallel_tests_db_test<%= ENV['TEST_ENV_NUMBER'] %>

.rspec_parallel

テスト結果の出力フォーマットを指定します。私の環境では、以下の設定を加えました。

--require spec_helper
--format progress
--format RspecJunitFormatter
--out tmp/rspec<%= ENV['TEST_ENV_NUMBER'] %>.xml

GitHub Actionでのワークフローファイル

parallel_testを使用したワークフローは以下のとおりです。

rspec.yml
name: "RSpec"
on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
jobs:
  rspec:
    runs-on: ubuntu-latest
    services:
      db:
        image: mysql:5.7
        env:
          MYSQL_DATABASE: parallel_tests_db_test
          MYSQL_ROOT_PASSWORD: password
        ports:
        - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Install Ruby and gems
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true

      - name: Set up database
        run: |
          RAILS_ENV=test bundle exec rake parallel:drop
          RAILS_ENV=test bundle exec rake parallel:create
          RAILS_ENV=test bundle exec rake parallel:migrate
        env:
          TZ: Asia/Tokyo
          MYSQL_USER: root

      - name: Run tests
        run: RAILS_ENV=test bundle exec rake parallel:spec
        env:
          TZ: Asia/Tokyo
          MYSQL_USER: root

parallel_tests 使用結果

GitHub ActionsではLinuxの仮想イメージは2コアとなっています。
https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources

Hardware specification for Windows and Linux virtual machines:

  • 2-core CPU (x86_64)
  • 7 GB of RAM
  • 14 GB of SSD space

そのためテストファイルは2分割され、実行されます。
実行結果は以下のとおりです。

2つのコアを使用して、テストが実行されていることがわかります。

おわりに

この記事では、parallel_testsを使用したテスト並列化について説明しました。
テストファイルやテスト数が多くなってくると、parallel_testsの恩恵を得やすくなり、全テスト完了までの時間が短縮されると思います。テストの実行時間が長い場合は、テスト自体の改善やリファクタリングを進めるとともに、parallel_tests導入をしていくと良さそうです。

この記事が誰かのお役に立てれば幸いです。

Discussion