CircleCI x Gradleマルチプロジェクトのテスト並列実行

この記事は [CircleCI Advent Calendar 2018](https://qiita.com/advent-calendar/2018/circleci) の12月18日の記事です。

## やりたいこと
- Gradleマルチプロジェクトのテストを複数コンテナで並列実行する

## 前提  
- CircleCI2.0
- 下記のようなGradleマルチプロジェクト(ここではSpring Boot x Groovy)

```p:プロジェクト構成
.
├── build.gradle
├── gradle
├── gradlew
├── gradlew.bat
├── project-one
├── project-two
├── project-three
├── project-four
├── project-five
├── project-six
└── settings.gradle
```

- docker-composeを動かしてその上でテスト実行
- コンテナを3つ利用


## docker-composeを動かす
`docker executer`だとvolumeマウントできないので`machine executer`にする必要があります
refs.
[CircleCI 2.0 で docker-compose を動かすなら、Machine Executor にしないとハマる](https://qiita.com/kunit/items/85e869b9a0ff7a30ac79)
[Docker コンテナにデータボリュームをマウントする方法 ](https://support.circleci.com/hc/ja/articles/360007324514-Docker-%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8A%E3%81%AB%E3%83%87%E3%83%BC%E3%82%BF%E3%83%9C%E3%83%AA%E3%83%A5%E3%83%BC%E3%83%A0%E3%82%92%E3%83%9E%E3%82%A6%E3%83%B3%E3%83%88%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95)

## テスト分割と割り当て
並列実行するためにテストクラスをコンテナの数だけ分割してあげる必要があります
最初にイメージしていたのは

- 1.ルートプロジェクト配下の全テストクラスをスクリプトで全取得
- 2.1を`circleci tests split`に渡して自動分割
- 3.2をGradleのtestタスクの引数に渡して一括実行

のような流れですが、Gradleには`絶対パスで指定したテストクラスをプロジェクト横断で一括実行する`という機能がないため詰まりました（あったら教えてください..）
下記のようにプロジェクト指定でのテストクラス指定実行は可能です
`./gradlew :project-one:test --tests "com.example.service.SampleServiceTest"` 

結果どうしたかというと、苦肉の策として
`CIRCLE_NODE_INDEX`という環境変数に実行コンテナのindexが格納されているので、どのコンテナにどのプロジェクトのテストクラスを割り当てるか手動で指定することにしました

下記のコードで  

- コンテナ1  
  - project-one
  - project-two
- コンテナ2
  - project-three
  - project-four
- コンテナ3
  - project-five
  - project-six

という割り当てでテスト実行が可能になります

``` bash:split_test.sh
#!/bin/bash

set -e

EXECUTE_MODULE=()
if [[ ${CIRCLE_NODE_INDEX} -eq 0 ]]; then
    EXECUTE_MODULE+=(one two)
elif [[ ${CIRCLE_NODE_INDEX} -eq 1 ]]; then
    EXECUTE_MODULE+=(three four)
elif [[ ${CIRCLE_NODE_INDEX} -eq 2 ]]; then
    EXECUTE_MODULE+=(five six)
fi

#コマンドの生成
COMMAND="./gradlew --parallel --max-workers=3 "
for item in ${EXECUTE_MODULE[@]}; do
    COMMAND=${COMMAND}":project-$item:clean :project-$item:test "
done

#実行
eval ${COMMAND}
```
refs.
[Using Environment Variables to Split Tests](https://circleci.com/docs/2.0/parallelism-faster-jobs/#using-environment-variables-to-split-tests)
[Running Tests in Parallel](https://circleci.com/docs/2.0/parallelism-faster-jobs/)


## キャッシュの設定
CircleCIのビルドは揮発性なのでキャッシュの設定をなにも行わないと都度ライブラリやパッケージのインストール等が行われてしまいます
今回はgradleのキャッシュ設定を行いました
プロジェクト配下の`build.gradle`/`settings.gradle`/`gradle-wrapper.properties`に変更があったときは新しくキャッシュkeyを生成し直します

``` bash:create_cache_key.sh
#!/bin/bash
#Gradleのキャッシュを使うかどうか判定するためのキー生成
#refs.https://medium.com/@chrisbanes/circleci-cache-key-over-many-files-c9e07f4d471a
RESULT_FILE=$1

if [ -f ${RESULT_FILE} ]; then
  rm ${RESULT_FILE}
fi
touch ${RESULT_FILE}

checksum_file() {
  echo `openssl md5 $1 | awk '{print $2}'`
}

# Gradleのビルドファイルを取得する
FILES=()
while read -r -d ''; do
	FILES+=("$REPLY")
done < <(find . \( -name 'build.gradle' -o -name 'settings.gradle' -o -name 'gradle-wrapper.properties' \) -type f -print0)

for FILE in ${FILES[@]}; do
	echo `checksum_file ${FILE}` >> ${RESULT_FILE}
done

# ソートする
sort ${RESULT_FILE} -o ${RESULT_FILE}
```
refs. [Caching Dependencies](https://circleci.com/docs/2.0/caching/)


ちなみにこのキャッシュKeyもコンテナごとに一意のものにしてあげないと、利用する依存ライブラリに差異が出てしまって毎回差分をfetchする形になるので指定してあげます

``` yml:config.yml
      - restore_cache:
          keys:
            - v1-gradle-dependencies-{{ .Environment.CIRCLE_NODE_INDEX }}-{{ checksum "/tmp/checksum.txt" }}

      - save_cache:
          #キャッシュは一度設定すると消せないのでgradleファイルに変更はないが消したい場合にはバージョンをインクリメントする
          #res:https://circleci.com/docs/2.0/caching/#clearing-cache
          key: v1-gradle-dependencies-{{ .Environment.CIRCLE_NODE_INDEX }}-{{ checksum "/tmp/checksum.txt" }}
```

## ターミナルの指定
なんでもいいのでターミナルを指定しないとgradlewrapper実行時に `* What went wrong:
Could not open terminal for stdout: $TERM not set`というエラーになるので指定します

```
export TERM=dumb
```

## テスト結果/成果物の保存
テスト結果や実行時間を綺麗なUIでみれることってめっちゃ大事ですよね
`store_test_results`を使うことで簡単に実現できます

```yml:config.yml
      - store_test_results:
          path: ~/junit
      - store_artifacts:
          path: ~/junit
```
refs.[Collecting Test Metadata](https://circleci.com/docs/2.0/collect-test-data/)

## 全体像

```yml:config.yml
version: 2
jobs:
  build:
    working_directory: ~/project
    machine:
      image: circleci/classic:201808-01
    environment:
      COMPOSE_FILE: ./docker-compose.yml
      SPLIT_TEST_PATH: ./circleci
    parallelism: 3
    steps:
      - checkout
      - run:
          name: Generate Cache Key
          command: bash ${SPLIT_TEST_PATH}/create_cache_key.sh /tmp/checksum.txt
      - run:
          name: Lancuh docker containers
          command: docker-compose up
      - restore_cache:
          keys:
            - v1-gradle-dependencies-{{ .Environment.CIRCLE_NODE_INDEX }}-{{ checksum "/tmp/checksum.txt" }}
      - run:
          name: Run Unit Test
          #ターミナルを指定しないと下記のようなエラーがでる
          #refs.https://stackoverflow.com/questions/49163104/how-to-resolve-term-not-set-on-gradlew-assemblerelease-on-circleci
          command: |
            export TERM=dumb
            bash ${SPLIT_TEST_PATH}/split_test.sh
      - save_cache:
          #キャッシュは一度設定すると消せないのでgradleファイルに変更はないが消したい場合にはバージョンをインクリメントする
          #res:https://circleci.com/docs/2.0/caching/#clearing-cache
          key: v1-gradle-dependencies-{{ .Environment.CIRCLE_NODE_INDEX }}-{{ checksum "/tmp/checksum.txt" }}
          paths:
            ~/.gradle
          when: always
      - run:
          name: Save Test Results
          command: |
            mkdir -p ~/junit/
            find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \;
          when: always
      - store_test_results:
          path: ~/junit
      - store_artifacts:
          path: ~/junit
```

## 今後やってみたいこと
- 2.1への書き換え
- [Docker Layer Caching](https://circleci.com/docs/2.0/docker-layer-caching/)の利用
    - 毎回dockerイメージを取り直してるしてるのでdockerのコンテナを立ち上げるだけで毎回ビルドが1~2mかかっています
    - これを利用するとdocker imageをキャッシュしてくれるのでビルドの高速につながるようです。ただ有料なのでおかねに余裕ができたらつかってみたいです

## 最後に
CircleCIは公式のドキュメントが非常に充実していてわかりやすく、初心者の自分でもあまり困ることはありませんでした　
今後も機会があったら活用したいと思います

以上、最後までみていただきありがとうございました