【Android】GitLab CI + Roborazzi でスクリーンショットテストを構築する

## 概要

Androidアプリの開発において、スクリーンショットテストを行うためのツールとして、Roborazziが有力な候補にあげられます。DroidKaigiや各社の技術ブログでもRoborazziの活用例に関する情報が多く発信されています。

ただ多くの場合は、GitHub Actionsを利用して実現されていることが一般的です。ただ私自身がGitLab CIを利用する状況にあったため、その実現方法に関する情報が不足していました。そこで、本記事ではGitLab CI上でRoborazziを活用したスクリーンショットテストの一例について整理します。

### 前提
- GitLab CIの実行環境が整備されていること
  - Linux, Dockerでの実行を想定 
- Roborazziのスクリーンショットテストをプロジェクトに導入済みであること
  - ネット上に多くの情報があるため、この記事では割愛します

### この記事で実現したいこと
- GitLab CIでRoborazziのスクリーンショットテストを実行する
- UI差分をMRにコメントとして投稿する


## スクリーンショットテストの全体像
複雑で大きなジョブになるため、`screenshot-test.gitlab-ci.yml`という個別のファイルにジョブを定義します。
メインの`.gitlab-ci.yml`から[include](https://docs.gitlab.com/ee/ci/yaml/includes.html)を用いて`screenshot-test.gitlab-ci.yml`のジョブを呼び出します。

```yml:.gitlab-ci.yml
include:
  - local: './${配置した任意のPath}/screenshot.gitlab-ci.yml'
```
`screenshot-test.gitlab-ci.yml`は以下のような構成になります。
GitLab CIはanchorという機能を使うことで、scriptをひとまとめにすることができます。
今回は処理ごとにanchorへ切り分けて可読性を確保します。

https://docs.gitlab.com/ee/ci/yaml/yaml_optimization.html

```yaml:screenshot-test.gitlab-ci.yml
# --------------------------------------------------------------------
# 定数の定義
# --------------------------------------------------------------------
variables:
  # GitLab REST APIのエンドポイント
  # 参考：https://docs.gitlab.com/ee/api/rest/
  GIT_LAB_API_BASE_URL: "https://${GitLabのHost URL}/api/v4/projects/${プロジェクトID}"

# --------------------------------------------------------------------
# anchorの定義
# --------------------------------------------------------------------
...

# --------------------------------------------------------------------
# 実行ジョブの定義
# --------------------------------------------------------------------
screenshot_test:
  stage: test
  # developブランチに向けたマージリクエストの発行時に実行する
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^develop.*/
  script:
    # gitのinstall
    - apt-get install git --yes >/dev/null
    # jsonを扱うjqのinstall
    - apt-get install jq --yes >/dev/null
    # 比較元のブランチを取得する
    - *clone_original_branch
    # スクリーンショットテストの実行
    - *generate_screenshot_compare_image
    # 比較結果のアップロード
    - *upload_image
    # MRに投稿するメッセージを組み立てる
    - *generate_mr_comment
    # MRにメッセージを投稿する
    - 'curl --request POST --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" --data "body=$(cat comment)" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"'
    # 古いメッセージを削除する
    - *delete_old_comment
```
## 各処理の詳細
### アクセストークンの設定

GitLab APIを利用してMRにコメントを投稿するにはアクセストークンが必要になります。
GitLabプロジェクトのSetting > Access Tokens から`api` scopeのアクセストークンを発行します。

https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html#create-a-project-access-token

発行したアクセストークンはGitLab APIのヘッダーに利用します。yamlに直接トークンを記述するとセキュリティ上よろしくないため、プロジェクトの設定から利用可能な変数として宣言します。
yamlから定数として利用できるように、 Settings > CI/CD > Variables に登録しましょう。

- key: `PROJECT_ACCESS_TOKEN`
- Value: 上記で取得したアクセストークンの値
 
https://docs.gitlab.com/ee/ci/variables/#for-a-project

### *clone_original_branch

- 目的
  - 比較対象のブランチをcloneして、ビルド可能な状態にする
- 処理
  - git cloneでtarget branchを取得する
  - androidプロジェクトのビルドに必要なsdkのパスを通す

GitLab CIではマージ元のブランチでジョブが実行されるため、比較対象のマージ先のターゲットブランチを取得する必要があります。
アクセストークンを利用することで、SSHでクローンすることが可能です。
参考： https://stackoverflow.com/questions/25409700/using-gitlab-token-to-clone-without-authentication
また、cloneしたままだとgradleコマンドを実行できないため、`local.properties`の作成と、`./gradlew`への実行権限の変更を忘れずに行います。

```yml
# 比較元のbranchを取得する
.clone_original_branch: &clone_original_branch
  - git clone -b $CI_MERGE_REQUEST_TARGET_BRANCH_NAME https://oauth2:${PROJECT_ACCESS_TOKEN}@gitlab.com/${プロジェクト名}.git
  # cloneしたブランチに移動する
  - cd ${cloneしたプロジェクトパス}
  # gradleコマンドを実行できるように設定を行う
  - touch local.properties
  - echo "sdk.dir=${android-sdkのパス}" >> local.properties
  - chmod +x ./gradlew
  - export GRADLE_USER_HOME=`pwd`/.gradle
  # 初期位置のディレクトリに戻る
  - cd ..
  ```
### *generate_screenshot_compare_image

- 目的
  - roborazziを利用してUI差分のスクリーンショットを生成する
  - `COMPARE_IMAGE_PATHS`にUI差分スクリーンショットのパスを格納する
- 処理
  - 比較元のブランチでスクリーンショットを生成
  - MRのブランチで差分スクリーンショットを生成
  - 生成された差分スクリーンショットのパスを`COMPARE_IMAGE_PATHS`に格納する

`./gradlew compareRoborazziDebug`でUI差分を生成するには、`build/outputs/roborazzi`に比較元の画像が必要になります。cloneしたソースで比較元画像を生成して、上記の比較用のパスに移動させます。
UI差分のスクリーンショットが生成されたら後続処理で利用できるように`COMPARE_IMAGE_PATHS`にパスを格納しておきます。

```yaml
.generate_screenshot_compare_image: &generate_screenshot_compare_image
  # cloneした比較元のソースに移動する
  - cd ${cloneしたプロジェクトパス}
  # 比較元画像を/build/outputs/roborazziに生成
  - ./gradlew :app:recordRoborazziDebug --stacktrace
  # 比較元画像を/build/outputs/roborazziに移動させる
  - cd ..
  - mkdir -p app/build/outputs/roborazzi
  - cp -r customer_app_android/app/build/outputs/roborazzi/* app/build/outputs/roborazzi/
  # MRのブランチで比較元のスクリーンショットとの差分を取得する
  - ./gradlew :app:compareRoborazziDebug --stacktrace
  # COMPARE_IMAGE_PATHSの変数にファイルパスを格納する
  - COMPARE_IMAGE_PATHS+=$(find app/build/outputs/roborazzi -type f -name "*compare*")
  # UI差分が0件なら正常終了
  - |
    if [ ${#COMPARE_IMAGE_PATHS[*]} -eq 0 ]; then
      echo "UIの差分はありませんでした"
      exit 0
    fi
```

### *upload_image

- 目的
  - UI差分の画像をMRで表示できるように、GitLabへアップロードする
- 処理
    - GitLab APIを利用して画像をアップロードして、URLを取得する
    - アップロードした画像URLを`UPLOAD_IMAGE_URLS`に格納する

`generate_screenshot_compare_image`で`COMPARE_IMAGE_PATHS`に画像が格納されています。これらをGitlab APIを利用してアップロードします。このAPIはアップロード後のパスをjsonで返すので、MRのコメント用に`UPLOAD_IMAGE_URLS`へ格納しておきます。

 https://docs.gitlab.com/ee/api/projects.html#upload-a-file

```yaml
# $COMPARE_IMAGE_PATHS をGitLabにアップロードして、$UPLOAD_IMAGE_URLS に変換する
.upload_image: &upload_image
  - UPLOAD_IMAGE_URLS=()
  - |
    for imagePath in $COMPARE_IMAGE_PATHS
    do
      response=$(curl --request POST --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" --form "file=@${imagePath}" "${GIT_LAB_API_BASE_URL}/uploads")
      UPLOAD_IMAGE_URLS+=($(echo $response | jq -r '.markdown'))
    done
```

### *generate_mr_comment

- 目的
    - MRのコメント文章を作成する
- 処理
    - `COMPARE_IMAGE_PATHS`と`UPLOAD_IMAGE_URLS`をテーブル形式に成形する

この辺りは自由ですが、テーブル形式でファイル名(`COMPARE_IMAGE_PATHS`)と対応させてUI差分を表示すると見やすい表現になります。一行ずつ`comment`というファイルにコメントを書き込んでいきます。

```yaml
# ファイル名とアップロードした画像URLをtable形式に整形する
# 整形したメッセージは comment ファイルに格納する
.generate_mr_comment: &generate_mr_comment
  - echo "📷<UIの変更を検知しました<br>" > comment
  - echo "|File name|Image|" >> comment
  - echo "|-|-|" >> comment
  - |
    for ((i = 0; i < ${#UPLOAD_IMAGE_URLS[*]}; i++))
    do
      # そのままだと文字列が長すぎるので、sedで30文字ごとに改行を挿入する
      path=$(echo ${COMPARE_IMAGE_PATHS[i]}| sed -r 's/.{30}/&<br>/g')
      echo "|${path}|${UPLOAD_IMAGE_URLS[i]}|" >> comment
    done
```

### MRへのコメント投稿

- 目的
    - MRにメッセージを投稿する
- 処理
    - notes APIを利用して`generate_mr_comment`で作成したコメントを投稿する

https://docs.gitlab.com/ee/api/notes.html#create-new-merge-request-note

```yaml
# MRにメッセージを投稿する
- 'curl --request POST --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" --data "body=$(cat comment)" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"'
```

### *delete_old_comment
- 目的
    - MRに投稿された古いコメントを削除する
- 処理
    - notes APIでMRのコメントを全件取得する
    - UI差分のコメントを抽出して、最新1件以外を削除する

スクリーンショットテストを複数回実行すると、そのたびにコメントが重複して投稿されるのは少し煩雑です。古いコメントは削除するようにジョブに組み込みます。
MRのコメントの取得、削除も同様にnotes APIで可能です。

https://docs.gitlab.com/ee/api/notes.html#merge-requests

 ```yaml
 
# 古いスクリーンショットのMRコメントを削除する
.delete_old_comment: &delete_old_comment
  # MRのコメントを取得する
  - |
    curl --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" >> comments.json
  # スクリーンショットテストに関するコメントIDを抽出する
  - screenshot_comment_ids=($(jq -r '.[] | select(.body | test("UIの変更を検知しました")) | .id' comments.json))
  # 全体に1件以下しかスクリーンショットテストに関するコメントが無い場合、正常終了
  - |
    if [ ${#screenshot_comment_ids[@]} -le 1 ]; then
      echo "スクリーンショットのコメントが1件以下なので、コメントの削除処理を終了します"
      exit 0
    fi
  # 最新1件以外のスクリーンショットテストに関するコメントは削除する
  - old_comment_ids=("${screenshot_comment_ids[@]:1}")
  - echo ${old_comment_ids[@]}
  - |
    for id in $old_comment_ids
    do
      echo "${id}のコメントIDを削除しました"
      curl --request DELETE --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes/${id}"
    done
```

### 完成形
ここまでの内容をまとめると以下の内容になります。

```yaml:screenshot-test.gitlab-ci.yml
# --------------------------------------------------------------------
# 定数の定義
# --------------------------------------------------------------------
variables:
  # GitLab REST APIのエンドポイント
  # 参考：https://docs.gitlab.com/ee/api/rest/
  GIT_LAB_API_BASE_URL: "https://${GitLabのHOst URL}/api/v4/projects/${プロジェクトID}"
  PROJECT_NAME: ${プロジェクト名}
  ANDROID_SDK_PATH: ${android-sdkのパス}
  
  # CI/CD variablesで定義
  # PROJECT_ACCESS_TOKEN: ...

# --------------------------------------------------------------------
# ジョブから呼び出されるscriptの定義
# --------------------------------------------------------------------

.clone_original_branch: &clone_original_branch
  - git clone -b $$CI_MERGE_REQUEST_TARGET_BRANCH_NAME https://oauth2:${PROJECT_ACCESS_TOKEN}@gitlab.com/${プロジェクト名}.git
  # cloneしたブランチに移動する
  - cd $PROJECT_NAME
  # gradleコマンドを実行できるように.setupと同等の準備をcloneしてきたプロジェクトに行う
  - touch local.properties
  - echo "sdk.dir=${ANDROID_SDK_PATH}" >> local.properties
  - chmod +x ./gradlew
  - export GRADLE_USER_HOME=`pwd`/.gradle
  - cd ..

.generate_screenshot_compare_image: &generate_screenshot_compare_image
  # cloneした比較元のソースに移動する
  - cd $PROJECT_NAME
  # 比較元画像を/build/outputs/roborazziに生成
  - ./gradlew :app:recordRoborazziDebug --stacktrace
  # 比較元画像を/build/outputs/roborazziに移動させる
  - cd ..
  - mkdir -p app/build/outputs/roborazzi
  - cp -r ${PROJECT_NAME}/app/build/outputs/roborazzi/* app/build/outputs/roborazzi/
  # MRのブランチで比較元のスクリーンショットとの差分を取得する
  - ./gradlew :app:compareRoborazziDebug --stacktrace
  # COMPARE_IMAGE_PATHSの変数にファイルパスを格納する
  - COMPARE_IMAGE_PATHS+=$(find app/build/outputs/roborazzi -type f -name "*compare*")
  # UI差分が0件なら正常終了
  - |
    if [ ${#COMPARE_IMAGE_PATHS[*]} -eq 0 ]; then
      echo "UIの差分はありませんでした"
      exit 0
    fi

.upload_image: &upload_image
  - UPLOAD_IMAGE_URLS=()
  - |
    for imagePath in $COMPARE_IMAGE_PATHS
    do
      response=$(curl --request POST --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" --form "file=@${imagePath}" "${GIT_LAB_API_BASE_URL}/uploads")
      UPLOAD_IMAGE_URLS+=$(echo $response | jq -r '.markdown')
    done

.generate_mr_comment: &generate_mr_comment
  - echo "📷<UIの変更を検知しました<br>" > comment
  - echo "|File name|Image|" >> comment
  - echo "|-|-|" >> comment
  - |
    for ((i = 0; i < ${#UPLOAD_IMAGE_URLS[*]}; i++))
    do
      # そのままだと文字列が長すぎるので、sedで30文字ごとに改行を挿入する
      path=$(echo ${COMPARE_IMAGE_PATHS[i]}| sed -r 's/.{30}/&<br>/g')
      echo "|${path}|${UPLOAD_IMAGE_URLS[i]}|" >> comment
    done
  - cat comment

.delete_old_comment: &delete_old_comment
  # MRのコメントを取得する
  - |
    curl --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" >> comments.json
  # スクリーンショットテストに関するコメントIDを抽出する
  - screenshot_comment_ids=($(jq -r '.[] | select(.body | test("UIの変更を検知しました")) | .id' comments.json))
  # 全体に1件以下しかスクリーンショットテストに関するコメントが無い場合、正常終了
  - |
    if [ ${#screenshot_comment_ids[@]} -le 1 ]; then
      echo "スクリーンショットのコメントが1件以下なので、コメントの削除処理を終了します"
      exit 0
    fi
  # 最新1件以外のスクリーンショットテストに関するコメントは削除する
  - old_comment_ids=("${screenshot_comment_ids[@]:1}")
  - echo ${old_comment_ids[@]}
  - |
    for id in $old_comment_ids
    do
      echo "${id}のコメントIDを削除しました"
      curl --request DELETE --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes/${id}"
    done

# --------------------------------------------------------------------
# 実行ジョブの定義
# --------------------------------------------------------------------
screenshot_test:
  stage: ✅ test
  # developブランチに向けたマージリクエストの発行時
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^develop.*/
  script:
    # gitのinstall
    - apt-get install git --yes >/dev/null
    # jsonを扱うjqのinstall
    - apt-get install jq --yes >/dev/null
    # 比較元のブランチを取得する
    - *clone_original_branch
    # スクリーンショットテストの実行
    - *generate_screenshot_compare_image
    # 比較結果のアップロード
    - *upload_image
    # MRに投稿するメッセージを組み立てる
    - *generate_mr_comment
    # MRにメッセージを投稿する
    - 'curl --request POST --header "PRIVATE-TOKEN: $PROJECT_ACCESS_TOKEN" --data "body=$(cat comment)" "${GIT_LAB_API_BASE_URL}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"'
    # 古いメッセージを削除する
    - *delete_old_comment
  artifacts:
    when: always
    paths:
      - '**/build/outputs/roborazzi'
    expire_in: 1 day
```

## まとめ
この記事ではGitLab CIでRoborazziを実現する方法について整理しました。
この例では`app`配下でのみ実行となっていますが、マルチモジュールの場合はモジュール毎にRoborazziのジョブを実行する必要があります。下記のような処理を追加することでマルチモジュールの場合にも対応できますね。

- git diffで変更のあるmoduleを検出する
- 変更のあるmoduleに対して`./gradlew :${module名}:recordRoborazziDebug`を実行

私自身はAndroidエンジニアを生業にしておりLinuxは不慣れでしたが、GitLab CIはLinuxコマンドをふんだんに使うため学びが多くありました。ChatGPTに聞けばLinux, CIの知識は十分にサポートしてくれるため、今後も恐れずに自動化へ挑戦していきたいですね。