안드로이드 앱 개발 시 UI 변경사항이 제대로 적용되었는지 확인하는 과정은 필수적입니다. 특히 다양한 화면 크기와 해상도를 지원해야 하는 안드로이드 환경에서는 더욱 중요합니다. 하지만 코드 리뷰만으로는 이러한 UI 변경사항을 정확히 파악하기 어렵고, 특히 팀 규모가 커질수록 의도치 않은 UI 변경을 놓치기 쉽습니다.
최근 저는 회사 프로젝트에서 이 문제를 해결하기 위해 Roborazzi를 활용한 스크린샷 테스트 시스템을 구축했습니다. 이 글에서는 Roborazzi
의 적용부터, GitHub Actions
를 활용한 CI 파이프라인 구축까지의 경험을 공유하려고 합니다.
Roborazzi 소개
Roborazzi는 Robolectric 기반의 안드로이드 스크린샷 테스트 라이브러리입니다. Robolectric의 Native Graphics Mode(RNG)를 활용하여 JVM 환경에서 실행되는 안드로이드 통합 테스트를 시각화할 수 있게 해줍니다. 아직 Experimental 단계이지만, Compose Multiplatform iOS와 Compose Desktop도 실험적으로 지원하고 있습니다.
Roborazzi의 가장 큰 장점은 실제 기기 없이 JVM 환경에서 스크린샷을 생성하고 비교할 수 있어, CI 환경에서 빠르게 테스트를 실행할 수 있다는 점입니다. 또한 Google의 공식 샘플 프로젝트인 Now in Android에도 적용되어 있어 실제 프로덕션 수준의 구현 예시를 찾아볼 수 있습니다.
Paparazzi와 Roborazzi 비교
Paparazzi는 JVM 환경에서 디스플레이를 시각화하는 훌륭한 도구이지만, 안드로이드 프레임워크를 모킹하는 Robolectric과는 호환되지 않습니다. Roborazzi는 이 간극을 메워줍니다. Robolectric과 통합되어 Hilt 등의 의존성 주입을 활용한 테스트가 가능하며, 실제 컴포넌트와 상호작용할 수 있습니다. 본질적으로 Roborazzi는 Paparazzi의 기능을 확장하여 Robolectric으로 스크린샷을 캡처함으로써 더 효율적이고 신뢰할 수 있는 테스트 프로세스를 제공합니다.
주요 Gradle 태스크
Roborazzi는 다음과 같은 Gradle 태스크를 제공합니다.
태스크명 | 설명 |
---|---|
recordRoborazziDebug | 스크린샷을 캡처하고 저장 |
compareRoborazziDebug | 현재 이미지와 저장된 이미지를 비교 |
verifyRoborazziDebug | 현재 이미지와 저장된 이미지 간의 차이 검증 |
verifyAndRecordRoborazziDebug | 이미지 검증 후 차이가 있으면 새 기준 이미지 기록 |
clearRoborazziDebug | 저장된 스크린샷 삭제 (실험적 기능) |
프로젝트 설정
Gradle 설정
먼저 프로젝트 레벨의 build.gradle.kts
에 Roborazzi 플러그인을 추가합니다.
// root build.gradle.kts
plugins {
// ...
id("io.github.takahirom.roborazzi") version "[version]" apply false
}
만약, buildScript 블록을 사용할 경우에는 아래와 같이 설정해줍니다.
// root build.gradle.kts
buildscript {
dependencies {
// ...
classpath("io.github.takahirom.roborazzi:roborazzi-gradle-plugin:[version]")
}
}
그리고 모듈 레벨의 build.gradle.kts
에 다음과 같이 설정합니다.
// module build.gradle.kts
plugins {
// ...
id("io.github.takahirom.roborazzi")
}
android {
// 기존 설정
testOptions {
unitTests {
isIncludeAndroidResources = true
// Robolectric의 하드웨어 렌더링 모드 사용
all {
it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"
}
}
}
}
dependencies {
// Core functions
testImplementation("io.github.takahirom.roborazzi:roborazzi:[version]")
// Jetpack Compose
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:[version]")
// JUnit rules
testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:[version]")
}
// Roborazzi 설정
roborazzi {
// 레퍼런스 이미지 저장 경로 설정
outputDir.set(file("src/test/screenshots"))
// 비교 이미지 저장 경로 (Experimental option)
compare {
outputDir.set(file("build/outputs/screenshots_comparison"))
}
}
gradle.properties 추가 설정
Roborazzi는 gradle.properties
파일에서 다양한 옵션을 설정할 수 있습니다.
# 스크린샷 테스트 활성화 옵션
roborazzi.test.record=true
# roborazzi.test.compare=true
# roborazzi.test.verify=true
# 이미지 리사이즈 스케일 설정
roborazzi.record.resizeScale=0.5
# 파일 경로 전략 설정
# 기본값은 relativePathFromCurrentDirectory
roborazzi.record.filePathStrategy=relativePathFromRoborazziContextOutputDirectory
# 이미지 포맷 설정 (WebP 지원, Experimental)
# roborazzi.record.image.extension=webp
# 오래된 스크린샷 자동 정리 (주의 필요)
# roborazzi.cleanupOldScreenshots=true
스크린샷 테스트 작성
이제 실제 스크린샷 테스트를 작성해보겠습니다. Roborazzi
는 다양한 방식의 스크린샷 캡처를 지원하며, 주요 API는 다음과 같습니다.
캡처 대상 | 코드 예시 |
---|---|
Jetpack Compose | composeTestRule.onNodeWithTag("AddBoxButton").captureRoboImage() |
Espresso View | onView(ViewMatchers.isRoot()).captureRoboImage() |
일반 View | view.captureRoboImage() |
Compose 람다 | captureRoboImage { Text("Hello Compose!") } |
전체 화면 (Experimental) | captureScreenRoboImage() |
Bitmap | bitmap.captureRoboImage() |
다음은 Jetpack Compose 기반 로그인 화면의 스크린샷 테스트 예시입니다.
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) // Robolectric Native Graphics 모드 활성화 (필수)
class LoginScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun captureLoginScreen_defaultState() {
// 컴포즈 UI 설정
composeTestRule.setContent {
MyAppTheme {
LoginScreen(
uiState = LoginUiState.Default,
onLoginClick = {},
onEmailChanged = {},
onPasswordChanged = {}
)
}
}
// 스크린샷 캡처
composeTestRule.onRoot()
.captureRoboImage(
// 파일명 지정 (기본 출력 디렉토리에 저장됨)
"loginScreen_defaultState",
// 추가 옵션 설정
roborazziOptions = RoborazziOptions(
// 이미지 리사이징
recordOptions = RoborazziOptions.RecordOptions(
resizeScale = 0.75f
),
// 비교 옵션
compareOptions = RoborazziOptions.CompareOptions(
changeThreshold = 0.01f
)
)
)
}
@Test
fun captureLoginScreen_errorState() {
composeTestRule.setContent {
MyAppTheme {
LoginScreen(
uiState = LoginUiState.Error("Invalid credentials"),
onLoginClick = {},
onEmailChanged = {},
onPasswordChanged = {}
)
}
}
// 특정 컴포넌트만 캡처할 수도 있음
composeTestRule.onNodeWithTag("errorMessage")
.captureRoboImage(
"loginScreen_errorMessage"
)
// 전체 화면 캡처
composeTestRule.onRoot()
.captureRoboImage(
"loginScreen_errorState"
)
}
}
RoborazziRule 활용
Roborazzi는 JUnit 규칙을 통해 더 편리한 테스트 작성을 지원합니다. RoborazziRule
은 선택적이지만, 다음과 같은 이점을 제공합니다.
RoborazziOptions
,outputDirectoryPath
등의 컨텍스트 제공- 다양한 캡처 유형 설정 (LastImage, AllImage, Gif 등)
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RuleTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val roborazziRule = RoborazziRule(
composeRule = composeTestRule,
captureRoot = composeTestRule.onRoot(),
options = RoborazziRule.Options(
// 테스트 실패 시에만 이미지 캡처
captureType = RoborazziRule.CaptureType.Gif(onlyFail = true),
// 이미지 비교 옵션 설정
roborazziOptions = RoborazziOptions(
compareOptions = RoborazziOptions.CompareOptions(
// 안티앨리어싱 문제 해결을 위한 설정
imageComparator = SimpleImageComparator(
maxDistance = 0.007f,
vShift = 2, // 수직 이동 허용
hShift = 2 // 수평 이동 허용
)
)
)
)
)
@Test
// RoborazziRule 무시 어노테이션
// @RoborazziRule.Ignore
fun testWithRule() {
composeTestRule.setContent {
MyAppTheme {
// ...
}
}
// RoborazziRule이 테스트 실행 과정에서 자동으로 이미지 캡처
}
}
GIF 이미지 캡처
Roborazzi는 애니메이션이나 사용자 상호작용을 보여주는 GIF 이미지 생성도 지원합니다.
@Test
fun captureRoboGifSample() {
onView(ViewMatchers.isRoot())
.captureRoboGif("build/test.gif") {
// 앱 실행
ActivityScenario.launch(MainActivity::class.java)
// 다음 페이지로 이동
onView(withId(R.id.button_first))
.perform(click())
// 뒤로 가기
pressBack()
// 다시 다음 페이지로 이동
onView(withId(R.id.button_first))
.perform(click())
}
}
다양한 디바이스 설정 테스트
실제 사용 환경을 더 잘 반영하기 위해 다양한 디바이스 설정(화면 크기, 방향, 다크 모드 등)에서 스크린샷을 캡처하는 것이 좋습니다. Roborazzi는 RobolectricDeviceQualifiers
를 통해 미리 정의된 디바이스 설정을 제공합니다.
// 클래스 레벨에서 기본 디바이스 설정
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel5)
class DeviceSpecificScreenshotTest {
// ...
}
// 특정 테스트 메서드에만 다른 설정 적용
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet + "+land")
fun captureLoginScreen_landscapeTablet() {
// ...
}
// 다크 모드 설정
@Test
@Config(qualifiers = "+night")
fun captureLoginScreen_darkMode() {
// ...
}
// 로케일 설정
@Test
@Config(qualifiers = "+ja") // 일본어 로케일
fun captureLoginScreen_japaneseLocale() {
// ...
}
운영체제별 렌더링 차이
다만, Roborazzi를 사용하면서 몇 가지 주의할 점들이 있습니다.
1. OS별 렌더링 불일치 문제
Roborazzi는 Skia 라이브러리를 사용하여 UI를 렌더링하는데, 이 렌더링 결과는 운영체제마다 약간씩 다를 수 있습니다. 이는 FAQ - Why do my screenshot tests fail inconsistently across different operating systems like MacOS, Ubuntu, and Windows?에서도 언급된 노운 이슈입니다.
이 문제는 Now in Android 프로젝트에서도 논의된 바 있으며, 모든 환경에서 동일한 렌더링을 보장할 수 없다는 점이 확인되었습니다. 참고로 이러한 이유로 저의 로컬 PC(Mac)에서도 스크린샷 검증 태스크를 수행하면 실패하였습니다. 주요 원인은 다음과 같습니다.
- 각 OS의 그래픽 드라이버 구현 차이
- 폰트 렌더링 방식의 차이
- 안티앨리어싱 처리 방식의 차이
이 문제를 해결하기 위해 RoborazziOptions.CompareOptions
의 옵션들을 변경해주어도 되겠지만, 저는 이렇게 조금씩 검증 조건을 완화해주다보면 스크린샷을 비교하는 의미가 없어진다는 생각이 들었습니다. 그래서 Roborazzi의 저자인 takahirom님의 의견에 따라 일관성을 위해 기준 이미지 생성과 검증을 모두 동일한 CI 환경에서 수행하도록 구성하였습니다.
실제로 nowinandroid에서도 이미지 비교 옵션에 대해서는 타협하지 않은 점을 확인할 수 있습니다.
2. 메모리 문제 해결
복잡한 UI에서 많은 스크린샷 테스트를 실행할 경우 OutOfMemoryError
가 발생할 수 있습니다. 이는 Issue #272에서도 논의된 문제로, 다음과 같이 maxHeapSize
를 조정하여 해결할 수 있습니다.
// build.gradle.kts
android {
testOptions {
unitTests.all {
maxHeapSize = "4096m"
}
}
}
3. 깨진 이미지 문제 해결
일부 경우에 Roborazzi로 캡처한 이미지가 깨져 보일 수 있습니다. 이는 주로 API 레벨이 너무 낮거나 Robolectric의 렌더링 모드 설정 문제일 수 있습니다. Issue #255에서 논의된 것처럼, 다음과 같은 설정으로 해결할 수 있습니다.
// 테스트 클래스에 적용
@Config(sdk = [35]) // 최신 API 레벨 사용
@GraphicsMode(GraphicsMode.Mode.NATIVE) // Native Graphics 모드 필수
// build.gradle.kts
android {
testOptions {
unitTests.all {
it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware"
}
}
}
이러한 설정을 통해 대부분의 렌더링 문제를 해결할 수 있었습니다.
참고로 Robolectric 4.14 버전부터 Android V(SDK 35)를 지원합니다.
CI 파이프라인 구축
테스트를 작성한 후, 이를 CI 파이프라인에 통합하는 것이 중요합니다. 저는 GitHub Actions를 활용하여 PR이 생성될 때마다 스크린샷 테스트를 자동으로 실행하고, 변경사항이 있을 경우 시각적으로 표시하는 시스템을 구축했습니다.
GitHub Actions 워크플로우 구성
다음은 GitHub Actions를 사용한 워크플로우 설정 예시입니다.
name: Android Pull Request CI
on:
pull_request:
branches: [ main, develop ]
jobs:
verify-screenshots:
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: write
steps:
- name: Checkout the code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run ktlint
run: ./gradlew ktlintCheck
# 스크린샷 검증 단계
- name: Verify Roborazzi Screenshots
id: screenshotsverify
continue-on-error: true
run: ./gradlew verifyRoborazziDebug
# 검증 실패 시 새 스크린샷 생성
- name: Generate new screenshots if verification failed
id: screenshotsrecord
if: steps.screenshotsverify.outcome == 'failure'
run: ./gradlew recordRoborazziDebug
# 브랜치명에서 JIRA 티켓 추출 (회사 상황에 맞게 조정)
- name: Extract JIRA ticket
id: extract_ticket
if: steps.screenshotsrecord.outcome == 'success'
run: |
BRANCH_NAME=$(echo ${{ github.head_ref }})
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
if [[ $BRANCH_NAME =~ (TICKET-[0-9]+) ]]; then
TICKET="${BASH_REMATCH[1]}"
echo "ticket=$TICKET" >> $GITHUB_OUTPUT
echo "Found JIRA ticket: $TICKET"
else
echo "ticket=NO-TICKET" >> $GITHUB_OUTPUT
echo "No JIRA ticket found in branch name"
fi
# 새 스크린샷 커밋
- name: Push new screenshots if available
uses: stefanzweifel/git-auto-commit-action@v5
if: steps.screenshotsrecord.outcome == 'success'
with:
file_pattern: '**/*.png'
disable_globbing: false
commit_message: "${{ steps.extract_ticket.outputs.ticket != 'NO-TICKET' && format('{0} feat: 스크린샷 업데이트', steps.extract_ticket.outputs.ticket) || 'NO-TICKET feat: 스크린샷 업데이트' }}"
# 비교 이미지 수집
- name: Copy screenshot comparison files
id: copy_screenshots
if: steps.screenshotsverify.outcome == 'failure'
run: |
mkdir -p /tmp/screenshot-diff
cd ${{ github.workspace }}
find . -name "*_compare.png" -exec cp {} /tmp/screenshot-diff/ \;
if [ -z "$(find /tmp/screenshot-diff -type f -name '*.png')" ]; then
echo "No screenshot files found"
echo "has_images=false" >> $GITHUB_OUTPUT
else
echo "Found screenshot comparison images:"
ls -la /tmp/screenshot-diff
echo "has_images=true" >> $GITHUB_OUTPUT
fi
# Companion Branch 생성
- name: Create companion branch for screenshot comparison
id: companion_branch
if: steps.copy_screenshots.outputs.has_images == 'true'
run: |
BRANCH_NAME="screenshot-compare-${{ github.event.pull_request.number }}"
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
git stash -u || true
git branch -D "$BRANCH_NAME" || true
git checkout --orphan "$BRANCH_NAME"
git rm -rf .
mkdir -p screenshot-diff
cp /tmp/screenshot-diff/*.png screenshot-diff/
git add screenshot-diff
git commit -m "Add screenshot comparison results"
git push origin "$BRANCH_NAME" -f
echo "companion_push_success=echo "companion_push_success=true" >> $GITHUB_OUTPUT
git checkout -f ${{ github.head_ref }} || git checkout -f HEAD
# 마크다운 리포트 생성
- name: Generate markdown report for screenshot diffs
id: generate_report
if: steps.companion_branch.outputs.companion_push_success == 'true'
run: |
REPORT="## 📸 스크린샷 비교 결과\n\n"
REPORT+="| 화면 이름 | 비교 이미지 |\n"
REPORT+="|:--------:|:----------:|\n"
BRANCH_NAME="${{ steps.companion_branch.outputs.branch_name }}"
for FILE in /tmp/screenshot-diff/*.png; do
FILENAME=$(basename "$FILE")
SCREENNAME=${FILENAME%_compare.png}
IMG_URL="https://github.com/${{ github.repository }}/blob/$BRANCH_NAME/screenshot-diff/$FILENAME?raw=true"
REPORT+="| \`$SCREENNAME\` |  |\n"
done
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "report<<$EOF" >> $GITHUB_OUTPUT
echo -e "$REPORT" >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
echo "found_images=true" >> $GITHUB_OUTPUT
# 기존 PR 코멘트 찾기
- name: Find existing comment
uses: peter-evans/find-comment@v3
id: find_comment
if: steps.generate_report.outputs.found_images == 'true'
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '## 📸 스크린샷 비교 결과'
# PR 코멘트 생성 또는 업데이트
- name: Create or update comment with comparison results
uses: peter-evans/create-or-update-comment@v4
if: steps.generate_report.outputs.found_images == 'true'
with:
comment-id: ${{ steps.find_comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: ${{ steps.generate_report.outputs.report }}
edit-mode: replace
# 단위 테스트 실행
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace
위 과정에 성공하면 다음과 같이 정상적으로 코멘트가 생성되는 것을 확인할 수 있습니다.

이 워크플로우는 다음과 같은 순서로 동작합니다.
- PR이 생성되면 스크린샷 검증 작업(
verifyRoborazziDebug
)을 실행합니다. - 검증에 실패하면(UI 변경이 있으면) 새로운 스크린샷을 생성합니다(
recordRoborazziDebug
). - 브랜치 이름에서 JIRA 티켓 번호를 추출하여 커밋 메시지에 포함합니다. 이 부분은 각 회사나 팀의 작업 방식에 맞게 조정할 수 있습니다. (예를 들어, JIRA 대신 GitLab 이슈 번호나 다른 형식의 작업 ID를 사용할 수 있을 것 같습니다.)
- 스크린샷 비교 이미지를 별도의 임시 브랜치(companion branch)에 저장합니다.
- 비교 이미지를 포함한 마크다운 보고서를 생성하여 PR 코멘트로 추가합니다.
Companion Branch 접근법
문득 이런 생각이 들 수도 있습니다. 스크린샷의 이미지가 과도하게 많아지면 저장소의 용량도 계속해서 증가하는 것은 아닐까?
그래서 Companion Branch Approach
가 DroidKaigi/conference-app-2022에서 제안되었습니다.
제가 작성한 워크플로우는 비교 이미지들은 메인 코드베이스의 저장소 크기가 커지는 것을 방지하기 위해 임시 브랜치에 저장되어 관리되도록 하였지만, 레퍼런스 이미지들은 저장소에 커밋하고 있습니다. 첨부한 사진처럼 레퍼런스 이미지들도 GitHub Actions Artifact에 업로드하여 이를 companion branch
에 생성한 비교이미지와 검증 태스크를 수행하는 방법으로 조금 더 개선할 수 있을 것 같습니다. 또한 Github Actions Artifact의 기본 보관 기간은 90일이지만, 보통 PR 생성 후 리뷰, 머지되기까지 길어봐야 1달 이내에 완료된다고 예상하면 보존 기간을 아래와 같이 30일로 설정할 수도 있습니다.
- name: Store reference images as artifacts
uses: actions/upload-artifact@v4
with:
name: reference-screenshots
path: src/test/screenshots
retention-days: 30
결론
Roborazzi를 활용한 스크린샷 테스트는 안드로이드 앱의 UI 품질을 높이는 데 큰 도움될 수 있습니다. 특히 CI 파이프라인과 통합하여 자동화된 UI 검증 시스템을 구축하면, 의도치 않은 UI 변경을 사전에 감지하고 시각적으로 확인할 수 있게 되어 좋았던 것 같습니다. 참고로 해당 포스팅에서 Roborazzi의 모든 기능들을 전부 다룬 것은 아닙니다. 릴리즈가 될때마다 신규 기능 추가 및 변경사항들을 꾸준히 모니터링하는 것이 중요할 것 같습니다.
참고 자료
- https://github.com/takahirom/roborazzi
- https://github.com/android/nowinandroid/blob/main/core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt
- https://github.com/android/nowinandroid/issues/1242#issuecomment-2032962982
- https://github.com/DroidKaigi/conference-app-2022/pull/616
- https://github.com/takahirom/roborazzi-compare-on-github-comment-sample
- https://robolectric.org/configuring/
- https://github.com/actions/upload-artifact?tab=readme-ov-file#retention-period