複数台で parallel_tests を動かす場合でも、実行時間ベースでテストを分割できるようにする

RSpec を並列に実行してくれるツールとして [parallel_tests](https://github.com/grosser/parallel_tests) があります。このツールは CPU 数などの情報から、自動で最適な並列数で RSpec などを複数同時に実行してくれるツールです。これにより通常よりも早くテストを完了することが出来ます。

https://github.com/grosser/parallel_tests

Qiita でも parallel_tests を使いつつ、複数のマシンを利用して CI での自動テストを回しています。(テストが大量にあるのと、テストを動かすマシンスペックの都合などで、こういう構成になっています。)

![parallel_tests_image.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7048/b8669d38-5417-8ca3-5680-9b5523a245e8.png)

こういう感じで並列にテストを回す場合は、**実行時間ベースでテストを分割して割り振る**ことで、テスト完了までの時間が早くなります。parallel_tests かつ複数マシンの場合、そうするのがやや面倒なのですが、うまく設定できたので、今回はそのやり方一例として紹介します。

## (前提知識): parallel_tests は複数個の RSpec コマンドを同時に実行している

parallel_tests は、以下のように内部で rspec コマンドを複数同時に実行することで、テストの並列実行を実現しています。 (`--verbose` オプションを付けると、実際にどういうコマンドを使っているかが表示されてわかりやすいです。)

```console
$ parallel_rspec --verbose -- spec/hoge_spec.rb spec/fuga_spec.rb
2 processes for 2 specs, ~ 1 specs per process
TEST_ENV_NUMBER= PARALLEL_TEST_GROUPS=2 rspec spec/hoge_spec.rb
TEST_ENV_NUMBER=2 PARALLEL_TEST_GROUPS=2 rspec spec/fuga_spec.rb
```

この際に parallel_rspec がやっていることとして、

* 使用可能なコア数などの情報を元に、最適な並列数を決定
* 複数の rspec コマンドにテストを割り振る
    * ※ ちなみに、割り振りの最小単位はファイル毎で、ファイル内のテストを別々に割り振ったりはしません
* 並列数や、自分が何番目かの情報を環境変数として渡す
* 最終的なテスト結果を表示できるように、専用の logger を提供

などがあります。

ここでのポイントとして、**複数の rspec コマンドに割り振るテストの負担が平等なほど、完了時間が早くなる** ということがあります。 複数実行している rspec のどれかが先に終わっても、他の rspec コマンドが終わるまで待つ必要があります。逆に言うと、各 rspec コマンドの完了時間が近いほど、無駄な待ち時間が少なくなるので早くなるということです。

## 1つのマシンで parallel_tests を動かす場合: `--runtime-log` でテスト結果をログに残せば OK

まず簡単な例として1つのマシンで parallel_tests を動かす場合を説明します。

parallel_tests には `--runtime-log` という、ログとしてファイルごとのかかる時間を残す設定があります。
このログを残しておくと、 次回以降、その情報を元に、実行時間順にテストを割り振ってくれるようになります。

```console
$ parallel_rspec --runtime-log runtime.log # テスト時間のログなどを runtime.log に出力

$ parallel_rspec --runtime-log runtime.log # 2回目以降は runtime.log の情報を元にテストを割り振ってくれる
```

ちなみに runtime.log の中身はこんな感じになってます。 `ファイル名:そのファイルにかかった時間` という形で出力されるのですが、たまに違う出力が紛れ込んだりすることがあります。

```
spec/hoge_spec.rb:74.60599300000467
spec/fuga.rb:26.98983700000099

Run options:
  include {:focus=>true}
  exclude {:ci=>true}

All examples were filtered out; ignoring {:focus=>true}
```

1台のマシンで parallel_tests を動かす場合はこれで十分です。

## 複数のマシンで parallel_tests を動かす場合

では冒頭で上げた、↓ のような構成の場合を考えてみます。

![parallel_tests_image.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7048/b8669d38-5417-8ca3-5680-9b5523a245e8.png)

ここで面倒なのが、

1. 各マシンへのテストの割り振り
2. parallel_tests による各 RSpec へのテストの割り振り

の2段階の割り振りが発生するということです。それぞれで実行時間順にテストを割り振るようしてあげる必要があります。

それぞれ用のログデータを用意する…でも良いのですが、データの扱いがそれなりに面倒なので、 `1. 各マシンへのテストの割り振り` に使っているデータを加工して `2. parallel_tests による各 RSpec へのテストの割り振り` で再利用する、という方式でやります。

### `1. 各マシンへのテストの割り振り`: [split-tests](https://github.com/mtsmfm/split-test) を使う

まず、各マシンへのテストの割り振りですが、ここでは [mtsmfm/split-test](https://github.com/mtsmfm/split-test) を使います。

このツールは JUnit Format XML という形式のデータファイルを使って、実行時間ベースでテストの割り振りを行ってくれます。JUnit Format XML ファイルの中身はこんな感じで、テスト毎の実行時間が記録されています。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="rspec" tests="3" skipped="0" failures="0" errors="0" time="7.777">
<properties>
<property name="seed" value="4353"/>
</properties>
<testcase classname="spec.hoge_spec" name="Hoge is not equal to Fuga" file="./spec/hoge_spec.rb" time="2.222"></testcase>
<testcase classname="spec.hoge_spec" name="Life is not easy" file="./spec/hoge_spec.rb" time="2.222"></testcase>
<testcase classname="spec.fuga_spec" name="Fuga is not equal to Hoge" file="./spec/fuga_spec.rb" time="3.333"></testcase>
</testcase>
```

実際の使い方はツールの作者による解説記事があるのでそちらを御覧ください。 (JUnit Format XML ファイルをどう出力するかは後の方で触れます)

https://zenn.dev/mtsmfm/articles/49dfc631f75ccc

### `2. parallel_tests による各 RSpec へのテストの割り振り`: JUnit Format XML から runtime log を生成する

で、次に parallel_tests によるテストの割り振りなのですが、 `1. 各マシンへのテストの割り振り` のためのデータを加工して再利用します。つまり JUnit Format XML から runtime log を生成します。

加工自体は XML データを parse して、テスト毎の実行時間のデータを、テストファイル毎に集計して出力するだけです。サンプル実装はこんな感じです。

```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'rexml/document'

doc = REXML::Document.new(STDIN.read)

results = REXML::XPath.match(doc, '/testsuite/testcase').each_with_object({}) do |testcase, results|
  file = File.absolute_path(testcase.attribute('file').value)
  time = testcase.attribute('time').value.to_f

  results[file] ||= 0.0
  results[file] += time
end

puts results.map { |(file, time)| "#{file}:#{time}" }.join("\n")
```

これによって生成した runtime log を `--runtime-log` に渡してあげれば OK です。

### JUnit Format XML データを (重複しない場所に) 出力する

最後に、テスト分割に利用する JUnit Format XML ファイルの出力方法について触れておきます。

JUnit Format XML ファイルの出力には [RSpecJunitFormatter](https://github.com/sj26/rspec_junit_formatter) を使うと良いです。 テスト結果の出力として JUnit Format XML ファイルを出力してくれます。

この際のポイントとして、以下のように、出力先を指定する際に `\$TEST_ENV_NUMBER` を含めるようにします。これをすることで parallel_tests が出力先を別にしてくれるようになります。

```console
$ parallel_rspec \
   --runtime-log runtime.log \
   --format RspecJunitFormatter --out rspec-\$TEST_ENV_NUMBER.xml \
   -- spec/hoge_spec.rb spec/fuga_spec.rb
2 processes for 2 specs, ~ 1 specs per process
TEST_ENV_NUMBER= PARALLEL_TEST_GROUPS=2 rspec --format RspecJunitFormatter --out rspec-.xml spec/hoge_spec.rb
TEST_ENV_NUMBER=2 PARALLEL_TEST_GROUPS=2 rspec --format RspecJunitFormatter --out rspec-2.xml spec/fuga_spec.rb
```

([mtsmfm/split-test](https://github.com/mtsmfm/split-test) へ渡す際はディレクトリを指定することで複数の JUnit Format XML ファイルを入力に出来るので、各マシンの JUnit Format XML ファイルを同じディレクトリに集約すれば OK です。)

ちなみにこの辺のデータを集めてまとめたりの集計は処理時間が少しかかるので、デフォルトブランチなど、実行時間があまり気にならないところでやるのがおすすめです。

## おわりに

という形で、 Qiita で実際に行っている、実行時間ベースでのテスト分割方法について紹介しました。

実行時間ベースでの分割を導入した場合の時間の改善について、具体的な比較は割愛させて頂くのですが、実行時間ベースにすることで、各 RSpec の実行時間のバラツキが小さくなり、全体の完了時間が `(1並列でテストを実行した場合の時間) / (並列数)` にかなり近づきました。
設定自体もそこまで難しくないので、長時間のテストを分割する場合はおすすめです。

ただ、複数台の parallel_tests 構成のデメリットとして、「ファイル単位以上に細かくテストを分割できない」ことがあり、巨大なテストファイルが存在すると、それがボトルネックになってしまうことがあります。そういったファイルを予め分割しておくなどをおすすめします。