Roborazzi로 안드로이드 스크린샷 변경점 시각화 테스트 구축하기

@onseok· March 09, 2025 · 20 min read

ui testing

안드로이드 앱 개발 시 UI 변경사항이 제대로 적용되었는지 확인하는 과정은 필수적입니다. 특히 다양한 화면 크기와 해상도를 지원해야 하는 안드로이드 환경에서는 더욱 중요합니다. 하지만 코드 리뷰만으로는 이러한 UI 변경사항을 정확히 파악하기 어렵고, 특히 팀 규모가 커질수록 의도치 않은 UI 변경을 놓치기 쉽습니다.

최근 저는 회사 프로젝트에서 이 문제를 해결하기 위해 Roborazzi를 활용한 스크린샷 테스트 시스템을 구축했습니다. 이 글에서는 Roborazzi의 적용부터, GitHub Actions를 활용한 CI 파이프라인 구축까지의 경험을 공유하려고 합니다.

Roborazzi 소개

RoborazziRobolectric 기반의 안드로이드 스크린샷 테스트 라이브러리입니다. Robolectric의 Native Graphics Mode(RNG)를 활용하여 JVM 환경에서 실행되는 안드로이드 통합 테스트를 시각화할 수 있게 해줍니다. 아직 Experimental 단계이지만, Compose Multiplatform iOSCompose 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은 선택적이지만, 다음과 같은 이점을 제공합니다.

  1. RoborazziOptions, outputDirectoryPath 등의 컨텍스트 제공
  2. 다양한 캡처 유형 설정 (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 환경에서 수행하도록 구성하였습니다.

now in android roborazzi compare options

실제로 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 1 4

참고로 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\` | ![Screenshot]($IMG_URL) |\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

위 과정에 성공하면 다음과 같이 정상적으로 코멘트가 생성되는 것을 확인할 수 있습니다.

(화면이름은 보안상 흰색박스로 가렸습니다.)
(화면이름은 보안상 흰색박스로 가렸습니다.)

이 워크플로우는 다음과 같은 순서로 동작합니다.

  1. PR이 생성되면 스크린샷 검증 작업(verifyRoborazziDebug)을 실행합니다.
  2. 검증에 실패하면(UI 변경이 있으면) 새로운 스크린샷을 생성합니다(recordRoborazziDebug).
  3. 브랜치 이름에서 JIRA 티켓 번호를 추출하여 커밋 메시지에 포함합니다. 이 부분은 각 회사나 팀의 작업 방식에 맞게 조정할 수 있습니다. (예를 들어, JIRA 대신 GitLab 이슈 번호나 다른 형식의 작업 ID를 사용할 수 있을 것 같습니다.)
  4. 스크린샷 비교 이미지를 별도의 임시 브랜치(companion branch)에 저장합니다.
  5. 비교 이미지를 포함한 마크다운 보고서를 생성하여 PR 코멘트로 추가합니다.

Companion Branch 접근법

문득 이런 생각이 들 수도 있습니다. 스크린샷의 이미지가 과도하게 많아지면 저장소의 용량도 계속해서 증가하는 것은 아닐까? 그래서 Companion Branch ApproachDroidKaigi/conference-app-2022에서 제안되었습니다.

companion branch approach

제가 작성한 워크플로우는 비교 이미지들은 메인 코드베이스의 저장소 크기가 커지는 것을 방지하기 위해 임시 브랜치에 저장되어 관리되도록 하였지만, 레퍼런스 이미지들은 저장소에 커밋하고 있습니다. 첨부한 사진처럼 레퍼런스 이미지들도 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의 모든 기능들을 전부 다룬 것은 아닙니다. 릴리즈가 될때마다 신규 기능 추가 및 변경사항들을 꾸준히 모니터링하는 것이 중요할 것 같습니다.

참고 자료

@onseok
배움을 배포하기