GitHub Actions上でparallel_testsを使いテストを並列実行する
こんにちは、M-Yamashitaです。
今回の記事は、parallel_testsを使ったテストの並列実行の話です。
執筆のきっかけとして、私が出会ったRailsのリポジトリにて、テストが多くCI完了までの時間が長いことに悩んでいました。一部のテストが遅いということは分かっていましたが、改善には時間がかかるようでした。
そのためCIを高速化するために、改善を進めつつ、別の切り口として何かないかと探していたところ、parallel_testsを見つけました。
このparallel_testsの理解を深めつつ、分かったことを記事として残したいと思い、執筆しました。
この記事で伝えたいこと
- parallel_testsとは何か?
- GitHub Actionsでのparallel_testsの使用
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コア数は以下メソッドで取得しています。
def determine_number_of_processes(count)
[
count,
ENV["PARALLEL_TEST_PROCESSORS"],
Parallel.processor_count
].detect { |c| !c.to_s.strip.empty? }.to_i
end
count
、ENV["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
を見てみます。
def processor_count
require 'etc'
@processor_count ||= Integer(ENV['PARALLEL_PROCESSOR_COUNT'] || Etc.nprocessors)
end
環境変数(PARALLEL_PROCESSOR_COUNT
)があればその値を、なければEtc.nprocessors
を呼び出しコア数とする単純なメソッドです。Etc
モジュールはrubyリポジトリが管理しているので、そのメソッドのドキュメントを確認すると、CPUコア数を
返すとあります。
有効な CPU コア数を返します。
require 'etc' p Etc.nprocessors #=> 4
以上より、parallel_testsは、環境変数もしくはEtc.nprocessors
を使ってコア数を取得していることが分かります。
それぞれのコアでテストを実行
コア数を取得できたので、次は並列実行の仕組みです。
並列実行は、以下メソッドで実行しています。
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
このなかで、実際に並列実行のテストをしているのは、以下のコードとなります。
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
メソッドでどうやってテストファイル一覧を取り出しているのか、見てみます。
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
ここで重要なのは、Parallel.map
です。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を使えるようにします。
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を作成できるようになります。
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を使用したワークフローは以下のとおりです。
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コアとなっています。
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