3
2

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

Last updated at Posted at 2024-05-29

概要

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を用いてscreenshot-test.gitlab-ci.ymlのジョブを呼び出します。

.gitlab-ci.yml
include:
  - local: './${配置した任意のPath}/screenshot.gitlab-ci.yml'

screenshot-test.gitlab-ci.ymlは以下のような構成になります。
GitLab CIはanchorという機能を使うことで、scriptをひとまとめにすることができます。
今回は処理ごとにanchorへ切り分けて可読性を確保します。

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のアクセストークンを発行します。

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

  • key: PROJECT_ACCESS_TOKEN
  • Value: 上記で取得したアクセストークンの値

*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への実行権限の変更を忘れずに行います。

# 比較元の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にパスを格納しておきます。

.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_imageCOMPARE_IMAGE_PATHSに画像が格納されています。これらをGitlab APIを利用してアップロードします。このAPIはアップロード後のパスをjsonで返すので、MRのコメント用にUPLOAD_IMAGE_URLSへ格納しておきます。

# $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_PATHSUPLOAD_IMAGE_URLSをテーブル形式に成形する

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

# ファイル名とアップロードした画像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で作成したコメントを投稿する

# 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で可能です。


# 古いスクリーンショットの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

完成形

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

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の知識は十分にサポートしてくれるため、今後も恐れずに自動化へ挑戦していきたいですね。

3
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2