あらしおブログ

技術ネタ中心の粗削りブログ

Jenkins を使って Gradle プロジェクトのテストを複数ノードで並列実行する方法

Parallel Test Executor Plugin は テストを分割し複数のノードで並列実行することができる Jenkins のプラグインです。 公式のチュートリアル には Maven プロジェクトでの使用例がありますが、Gradle での使い方が見つからなかったので私が試した方法を紹介したいと思います。

テスト並列実行のしくみ

Parallel Test Executor を使った並列テストのしくみについて簡単に。 並列テストの実行には以下の機能が関わっています。

  • Jenkins 組み込みの並列処理機能 (Jenkins 2.x のパイプライン)
  • Parallel Test Executor によるテスト振り分け
  • Maven や Gradle などビルドツールによる特定のテスト除外機能 (exclude 指定)

そして、テストの並列実行は以下のように実現されます。

  1. 一つ前の成功ビルドのテスト結果 (各テストクラスの所要時間) を参照する
  2. テスト時間が各ノードで均等になるようにテストを振り分ける
  3. 振り分けたテストを各ノードで並列実行する

このうち、1 と 2 は Parallel Test Executor が良きに計らってくれる部分です。 3 については、ビルドツールで割り振られたテスト以外は実行しない (exclude) ように自分で設定する必要があります。

実績をベースにテストの振り分けを自動化しているのがうれしいですね。

Gradle プロジェクトでの使用例

Gradle で管理される Java プロジェクトの JUnit テストを3つのノードで並列実行する例を紹介します。

1. Gradle のサンプルプロジェクト

並列テスト対象の Gradle のサンプルプロジェクトを GitHub に上げました。
https://github.com/arasio/simple-gradle-project-with-tests

プロジェクトには以下のようなテストが Sample1Test.java ~ Sample9Test.java まで9つ入っています。

public class Sample1Test {
    @Test
    public void test() throws InterruptedException {
        assertEquals(3, 1 + 2);
        Thread.sleep(10000);
    }
}

Sample1Test は sleep の値が 10秒, Sample2Test は 20秒, ... , Sample9Test は 90秒 というように設定しています。 そのままシーケンシャルにテストを実行すると、7分30秒+α かかる計算になります。

2. Jenkins のノード準備

予め並列実行する3つのノードを Jenkins に登録しておきます。
詳しい手順は省略しますが、サーバをセットアップして

  • JDK のインストール
  • jenkins ユーザの作成
  • Gradle のインストールとパスを通す

などを済ませます。 そして、Jenkins の管理 > ノードの管理 > 新規ノードの作成 からノードを追加します。 ここで、3つのノードにはすべて slaves というラベルを付けておくことにします。

f:id:arasio:20161013010553p:plain

3. Jenkins のパイプラインジョブ設定

Parallel Test Executor プラグインをインストールすると、 パイプラインスクリプト (jenkinsfile) で splitTests というメソッドが使えるようになります。 引数の size に分割数(ノード数) を設定すると、返り値として各ノードで除外すべきテストクラスのリスト(の配列)が得られます。 各ノードでこれを exclusions.txt に保存 → exclusions.txt にないテストのみ実行(4参照) → テスト結果をアーカイブして集約 という流れになります。

下は slaves ラベルがついた 3 つのノードでテストを並列実行する Jenkinsfile のサンプルです。

project = 'simple-gradle-project-with-tests'
repositoryUrl = "https://github.com/arasio/${project}.git"

node('master') {
    stage('checkout') {
        deleteDir()
        git url: repositoryUrl
        stash name: project
    }
}

stage('parallel test') {
    def splits = splitTests parallelism: count(size: 3)
    def branches = [:]
    
    for(int i = 0; i < splits.size(); i++) {
        def exclusions = splits.get(i);
        branches["split${i}"] = {
            node('slaves') {
                deleteDir()
                unstash project
                writeFile file: 'exclusions.txt', text: exclusions.join('\n')
                sh "gradle build"
                step([$class: 'JUnitResultArchiver', testResults: '**/build/test-results/*.xml'])
            }
        }
    }
    parallel branches
}

4. build.gradle の設定

ここで Gradle プロジェクトに話を戻します。 Gradle では test タスクの exclude を使ってテスト対象外にしたいテストクラスを指定できます。 いろいろ探してみたのですが、exclusions.txt のような、除外クラスをリストアップしたファイルを直接指定するメソッドは Gradle にはないようです。 そこで以下のようにファイルを読み込んで一行一行、exclude に追加する処理を仕込んでおきます。

test {
  def exclusionList = new File('exclusions.txt')
  if(exclusionList.exists()) {
    exclusionList.eachLine { line ->
      exclude line
    }
  }
}

build.gradle: https://github.com/arasio/simple-gradle-project-with-tests/blob/master/build.gradle

5. ジョブの実行

並列テストを実行する準備が整ったのでジョブを実行してみます。

初回のビルドは過去の実績(テスト時間)がないので、すべてのテストがどれか1つのノードで実行されます。 2回目以降はテストが分割されて並列実行されるようになります。

* テスト実行結果

テスト時間は以下のように 7分47秒 (初回,シーケンシャル) → 3分12秒 (2回目,並列) になりました。1/3 とまではいきませんが、半分以下までテスト時間を短縮することができました。

f:id:arasio:20161013215633p:plain

* テスト分割の内訳を確認してみる

分割の内訳は以下のようになっていました。理想ではありませんが、ほぼ均等に振り分けられているのがわかります。

ノード 実行したテスト テスト時間
#1 Sample1Test, Sample4Test, Sample9Test 140s
#2 Sample3Test, Sample6Test, Sample7Test 160s
#3 Sample2Test, Sample5Test, Sample8Test 150s
* テストレポートを確認してみる

テスト結果を確認してみましょう。 ちゃんと9つのテスト結果が集約されたレポートになっています。また、ここで表示されるテスト時間は、各ノードで要した時間が合算されたものになるようです。

f:id:arasio:20161013214903p:plain

* その後、テストが増えたら振り分けはどうなる?

新しく追加されたテストはすぐには振り分け対象になりません。 過去の実績をもとに、除外するテストをリストアップする方法をとっているので、 新しいテストはどのノードでも除外されない、つまりすべてのノードで実行されることになります。 一度ビルドが成功すればその後は振り分け対象になります。

おまけ: Gradle の maxParallelForks もあるよ

Jenkins とは関係ないですが、Gradle の Java プラグインにはテストを並列実行するための機能 maxParallelForks があります。 ただし、こちらは同一マシン上でプロセスを複数立ち上げて並列実行するものです。 test.maxParallelForks = 4 のように並列実行数を指定するだけなので、 Jenkins のプラグインを使うよりはるかに簡単です。 もし、以下のような条件がそろっている場合は maxParallelForks を使ったほうがよいですね。

  • テストがデータベースなどに依存しておらず、完全に独立している
  • マシンのリソース的にも同一マシン上の複数プロセスでテストを並列実行するだけの余裕がある