본문 바로가기

Android/Tech

Roborazzi 로 UI 테스트 커버리지 제고하기

Kelly Sikkema, Unsplash.

동기

사내 전동 출입문과 연동이 되는 근태 및 출입 관리 앱을 운영하고 있습니다. 서비스 안정화 단계에 접어들면서 오류 사항 개선 정도만 진행하게 될 줄 알았으나, 전직원이 사용하는 앱이다 보니 이런 저런 추가 요구 사항이 수시로 들어오고 있습니다. 일주일에 한 번은 새로운 기능이나 마이너한 변경이 있는 것 같습니다.

 

모바일 앱의 경우 1인 개발 환경이고, 현재 맡아 진행하고 있는 다른 프로젝트도 많아서 해당 서비스에 많은 시간을 쏟을 수가 없는 상황입니다. 그렇다 보니 매번 배포 시 UI 관련 오류 발생에 대한 불안감이 늘 존재합니다. 그렇다고 다양한 기기에서 시나리오를 따라 UI 하나씩 검증하는 수동 E2E 테스트는 그 피로도가 너무 높고요.


왜 테스트 커버리지를 제고하여야 하는가

테스트 커버리지는 전체 기능이나 코드 중 얼마나 많은 부분을 검증하는지 나타내는 지표입니다. 즉, '내가 앱 코드 중 몇 %를 테스트 환경에서 실행하고 있느냐' 입니다. 이게 방향이 좀 잘못되면 '테스트 커버리지가 100% 면 앱에 결함이 없다' 라고 여기게 될 수 있는데, 사실 그렇지 않습니다. '테스트는 결함이 존재함을 알려줄 뿐, 결함이 없음을 증명할 수 없다' 는 격언이 말해주듯, 늘 적절한 테스트를 진행할 수 있도록 만전을 기해야 합니다.

 

그래서 사실 저는 테스트 커버리지에 집착하지는 않습니다. 테스트를 행하는 목적은 어떤 문제가 존재하는지 파악하기 위함이지, 커버리지를 100% 로 만들어 앱을 '내 테스트가 다 통과하는 상태' 로 만들기 위함이 아니니까요. 

 

그럼에도 불구하고요, 앞 뒤 맞지 않는 말이지만 커버리지를 반드시 제고하여야 하는 이유는 높은 커버리지가 곧 최소한의 안전망이 될 수 있기 때문입니다. 개발자가 진행하는 오류 추정 테스트에는 한계가 있기에, 의도적으로 테스트 커버리지를 높여 문제를 발견할 확률을 증가시키는 겁니다. 커버리지가 높으면 문제를 발견할 확률이 증가하고, 일단 문제를 발견하면 늘 그래왔듯 해결할 수 있습니다.


Roborazzi

Roborazzi 는 Jetpack Compose 또는 전통적인 Android View System UI 화면을 JVM 에서 렌더링하고 이미지로 저장한 뒤, 기존에 저장된 골든 마스터(테스트의 기준이 되는) 이미지와 비교하여 픽셀 단위로 UI 변화를 감지하는 스냅샷 기반 UI 테스트 라이브러리입니다. 

 

GitHub - takahirom/roborazzi: Make JVM Android integration test visible 🤖📸

Make JVM Android integration test visible 🤖📸. Contribute to takahirom/roborazzi development by creating an account on GitHub.

github.com

 

Roborazzi 는 실제 기기나 에뮬레이터를 사용하지 않고 Robolectric 프레임워크를 핵심 가교로 삼아 JVM 내부에서 동작합니다. Robolectric NATIVE Grahpics Mode 를 활성화하면 Composable 메서드의 Measure, Layout, Draw 로 이어지는 Jetpack Compose 파이프라인을 JVM 스레드 내에서 모사하여 수행할 수 있습니다.

 

Draw 단계의 가상 Canvas 로부터 원시 픽셀 비트맵 데이터를 추출한 뒤, 이를 물리적 이미지 파일로 쓰기 작업을 진행합니다. 이후 기존에 준비한 스냅샷 이미지와 새롭게 렌더링된 이미지 파일 간 바이트 배열 대조를 통해 테스트의 성공 여부를 기계적으로 판별합니다. '기계적 판별' 이 핵심인데, 인간의 눈으로 UI 를 시안과 비교하며 구현을 검증하는 방식이 아니기 때문에 더욱 세밀한 테스트가 가능합니다.

 

 

복잡하게 설명했지만, 사실 매우 간단합니다. 정상적인 앱 스크린샷을 보관해뒀다가, 테스트를 실행하면 앱 스크린샷을 새로 찍어서 기존에 찍어놨던 것과 픽셀을 1:1로 대조 후 똑같으면 테스트를 통과시키는 겁니다. 다만, 이 과정이 실제 기기나 에뮬레이터가 아닌 JVM 테스트 환경에서 수행되므로 매우 빠르며, 이를 통해 개발 리소스를 아낄 수 있다는 데에 의의가 있습니다.

fun processOutputImageAndReportWithDefaults(
  canvas: RoboCanvas,
  goldenFile: File,
  roborazziOptions: RoborazziOptions,
) {
  processOutputImageAndReport(
    newRoboCanvas = canvas,
    goldenFile = goldenFile,
    roborazziOptions = roborazziOptions,
    emptyCanvasFactory = { width, height, filled, bufferedImageType ->
      AwtRoboCanvas(
        width = width,
        height = height,
        filled = filled,
        bufferedImageType = bufferedImageType
      )
    },
    canvasFactoryFromFile = { file, bufferedImageType ->
      AwtRoboCanvas.load(file, bufferedImageType, roborazziOptions.recordOptions.imageIoFormat)
    },
    comparisonCanvasFactory = { goldenCanvas, actualCanvas, resizeScale, bufferedImageType ->
      AwtRoboCanvas.generateCompareCanvas(
        AwtRoboCanvas.Companion.ComparisonCanvasParameters.create(
          goldenCanvas = goldenCanvas as AwtRoboCanvas,
          newCanvas = actualCanvas as AwtRoboCanvas,
          newCanvasResize = resizeScale,
          bufferedImageType = bufferedImageType,
          oneDpPx = run {
            val dip = 1f
            val r: Resources = ApplicationProvider.getApplicationContext<Context>().resources
            val px = TypedValue.applyDimension(
              TypedValue.COMPLEX_UNIT_DIP,
              dip,
              r.getDisplayMetrics()
            )
            (px * resizeScale).toFloat()
          },
          comparisonComparisonStyle = roborazziOptions.compareOptions.comparisonStyle
        )
      )
    }
  )
}

 

Roborazzi 의 가장 핵심적인 메서드 본문입니다.(이상은 IDE 에서 파악할 수 없습니다.) 파라미터로 전달되는 canvas 는 테스트 시에 캡처한 새로운 이미지이고, goldenFile 이 레퍼런스 이미지입니다. 레퍼런스 이미지를 불러 올 Canvas 와 새로 캡처한 이미지의 Canvas 를 활용, 픽셀 대조를 통해 이미지의 차이를 짚어냅니다. 이후 차이가 있다면 새로운 Canvas 를 생성하고 결과를 그려 리포트로 남기는 방식입니다. 자세한 내부 동작 방식은 아래 페이지에서 확인하실 수 있습니다.

 

roborazzi/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.common.k

Make JVM Android integration test visible 🤖📸. Contribute to takahirom/roborazzi development by creating an account on GitHub.

github.com

실패 시 테스트 결과

 

테스트에 실패하는 경우, 제 3의 Canvas 에 이와 같이 그려집니다. 화면 어느 영역에 차이가 있는지 붉은 색상으로 표시가 되므로 코드 수정에 대단히 용이합니다.

 

이러한 UI 회귀 테스트만으로도 충분히 의미가 있지만, 개인적으로 더 좋은 부분은 여러 해상도에 화면을 대응하기 위한 시뮬레이터로 활용할 수 있다는 것입니다.

private data class DeviceSpec(
    val name: String,
    val qualifier: String
)
private val targetDevices = listOf(
    DeviceSpec(
        name = "Galaxy_S26",
        qualifier = "w360dp-h800dp-port-xxhdpi"
    ),
    DeviceSpec(
        name = "Z_Fold_Opened",
        qualifier = "w904dp-h904dp-land-xxhdpi"
    ),
    DeviceSpec(
        name = "Tri_Fold_Unfolded",
        qualifier = "w1024dp-h800dp-land-hdpi"
    ),
    DeviceSpec(
        name = "Pixel_Tablet",
        qualifier = "w1280dp-h800dp-land-mdpi"
    )
)

/**
 * 지정된 모든 레퍼런스 기종 프로파일(targetDevices)을 순회하여 UI 상태 스냅샷을 캡처하는 공통 헬퍼 메소드
 */
private fun captureMultiDeviceImage(
    stateName: String,
    uiState: AccessUiState
) {
    targetDevices.forEach { device ->
        RuntimeEnvironment.setQualifiers(device.qualifier)
        captureRoboImage(
            filePath = "src/test/snapshots/AccessControl_${stateName}_${device.name}.png"
        ) {
            AccessControlTheme {
                AccessControlContent(
                    uiState = uiState,
                    onToggleWorkStatus = {},
                    onSelectGate = {},
                    onRequestAccess = {},
                    onDismissDeniedDialog = {},
                    onDismissNetworkErrorDialog = {}
                )
            }
        }
    }
}

 

저는 이와 같이, 별도의 data class 를 선언해서 사용했습니다. 이후 forEach {} 메서드를 통해 여러 해상도의 스크린샷을 만들도록 합니다. 

@Test
fun `Given 사내 출입 관리 화면이 주어졌을 때 - When 기본 로드된 상태인 경우 - Then 기종별 화면 스크린샷을 일괄 검증한다`() {
    val uiState = AccessUiState(
        employee = mockEmployee,
        workStatus = WorkStatus.OUTSIDE,
        gates = mockGates,
        selectedGateIndex = 0,
        recentRecords = emptyList()
    )
    captureMultiDeviceImage(
        stateName = "Default",
        uiState = uiState
    )
}

 

BehaviorSpec 이 익숙해서 테스트 메서드 역시 BehaviorSpec 에 맞게 작성했습니다. 

 

4개의 테스트 코드를 실행한 결과. 서로 다른 해상도의 이미지가 저장된다.

 

단순히 회귀 테스트 용도 뿐만 아니라, 다양한 기기에서 내가 개발한 Composable 이 어떻게 표시되는지 빠르게 확인할 수 있어 좋습니다. 물론 Compose Preview 에서도 가능하긴 하지만요, 디자이너 분들이나 기획자 분들께 개발된 화면을 전달할 때 굉장히 용이합니다.


테스트 커버리지 측정

 

Jacoco 로 테스트 커버리지를 측정했으며, 플래그에 따라 UI 가 달라지는 경우에 대한 처리만 추가한 꽤 단순한 코드임에도 87% 정도의 UI 커버리지를 확보할 수 있습니다. 


조금 더 활용하기

단순한 코드 테스트 툴이 아닌 스냅샷 기반 UI 테스트 툴이므로, 우리는 이를 더 다양한 방식으로 활용할 수 있겠습니다. 가령, 단순히 테스트 성공 여부만 전달하는 것이 아니라 UI 가 변경된 부분을 별도 테이블로 정리하여 PR 에 포함시키는 방식처럼요. 아래와 같습니다. 

 

이렇게 CI 를 구축해두면 PR 을 리뷰하는 입장에서 UI 상 어떤 변화가 있는지 빠르게 확인할 수 있어 큰 도움이 됩니다. PR 을 생성하는 입장에서도 의도치 않은 UI 변경이 있는 경우, PR 생성을 완료하기 전에 문제를 파악하고 해결할 수 있고요. 테이블 생성 파일을 첨부할테니, 필요하시면 받아서 사용하시면 되겠습니다.

ui-test.yml
0.01MB


피그마와 통합도 가능한데요. 피그마에 그려진 UI 노드의 ID 를 활용하여 비트맵으로 추출한 뒤, 이를 로컬로 가져와 Roborazzi 테스트를 수행하는 방식입니다. 꽤 간단한데 실용성이 좋은 방식입니다.

 

먼저, Figma Access Token 을 생성하고 이를 보관합니다. 계정 설정 내 Personal access tokens 탭에서 이를 생성하고 관리할 수 있습니다.

 

해당 프로젝트의 ID 가 필요합니다. /design 과 프로젝트 타이틀 사이의 문자열이 프로젝트의 ID 입니다.

 

 획득한 Figma Access Token 과 프로젝트 ID 는 LocalProperties 에 아래와 같이 작성합니다.

 

추출하고자 하는 노드의 ID 는 해당 노드를 선택하면 URL 에 포함되므로, 이를 가져오도록 합니다. 사전 준비는 이정도로 끝이고, 이제 앱 수준 또는 UI 모듈 수준 build.gradle 에 GradleTask 를 추가합니다.

tasks.register("downloadFigmaDesign") {
    group = "design"
    description = "Downloads design frames from Figma API to use as reference snapshots for Roborazzi."

    doLast {
        val localProperties = Properties().apply {
            val localPropertiesFile = project.rootProject.file("local.properties")
            if (localPropertiesFile.exists()) {
                localPropertiesFile.inputStream().use { load(it) }
            }
        }
        
        val figmaToken = localProperties.getProperty("figma.token") 
            ?: System.getenv("FIGMA_ACCESS_TOKEN")
        val fileKey = localProperties.getProperty("figma.fileKey")
            ?: System.getenv("FIGMA_FILE_KEY")

        if (figmaToken.isNullOrEmpty() || fileKey.isNullOrEmpty()) {
            logger.lifecycle("⚠️ figma.token 또는 figma.fileKey 설정이 누락되었습니다. local.properties 혹은 환경변수를 설정해 주세요.")
            return@doLast
        }

        // 비교하고자 하는 Figma 프레임 Node ID 매핑 정의
        // key: 로컬에 저장될 파일 이름, value: Figma Node ID
        val figmaNodeMap = mapOf(
            "AccessControl_Default_Galaxy_S26" to "1:13"
        )

        val nodeIds = figmaNodeMap.values.joinToString(",")
        val apiUrl = "https://api.figma.com/v1/images/$fileKey?ids=$nodeIds&format=png&scale=2.0"

        logger.lifecycle("Figma API 호출 중: $apiUrl")
        val connection = URL(apiUrl).openConnection() as HttpURLConnection
        connection.setRequestProperty("X-Figma-Token", figmaToken)
        connection.requestMethod = "GET"

        if (connection.responseCode != 200) {
            val errorText = connection.errorStream?.bufferedReader()?.readText() ?: ""
            throw GradleException("Figma API 호출 실패 (응답코드: ${connection.responseCode}): $errorText")
        }

        val jsonResponse = connection.inputStream.bufferedReader().readText()
        val slurper = groovy.json.JsonSlurper()
        val result = slurper.parseText(jsonResponse) as Map<*, *>
        val images = result["images"] as Map<*, *>

        figmaNodeMap.forEach { (fileName, nodeId) ->
            val imageUrl = images[nodeId]?.toString()
            if (imageUrl.isNullOrEmpty()) {
                logger.lifecycle("Node ID $nodeId 에 매칭되는 이미지를 Figma에서 찾지 못했습니다.")
                return@forEach
            }

            logger.lifecycle("Figma 이미지 다운로드 시작 ($fileName): $imageUrl")
            val destFile = file("src/test/snapshots/$fileName.png")
            destFile.parentFile.mkdirs()

            URL(imageUrl).openStream().use { input ->
                destFile.outputStream().use { output ->
                    input.copyTo(output)
                }
            }
            logger.lifecycle("저장 완료: ${destFile.absolutePath}")
        }
    }
}


복사해서 사용하시면 되겠습니다.

중간에 figmaNodeMap 이름으로 생성한 Map<String, String> 변수에 원하는 값을 넣어줍니다. 키는 파일 명, 값은 노드 ID 입니다.

 

이렇게 하면 준비가 끝납니다. 복잡하고 거창해보이지만, 사실 피그마에 작성된 노드를 비트맵으로 추출하여 작성한 Composable 과 비교하는 것이 전부입니다. 다만 이를 노드 ID 로 관리하기 때문에 피그마 쪽에 변경이 있거나 앱 쪽에 변경이 있는 상황에 대한 대응이 가능하다는 건데요. 개발 또는 UI QA 시 '이거 왜 다른가요?' 와 같은 상황을 원천 차단할 수 있는 방법이라 매우 좋습니다.(물론 CI 에 적용해놓아야 의미가 있는 것 같습니다.)

 

전혀 다른 UI 시안과 비교했을 때, 픽셀 간 비교 결과가 모두 다르므로 이와 같은 결과가 표시됩니다.


마지막으로, 해상도 대응을 넘어 앱 개발의 영원한 숙제, 폰트 사이즈와 화면 사이즈 대응입니다. 이 역시 Compose Preview 로 대응할 수 있는 부분이긴 하지만요, 문제가 발생할 것 같은 값을 넣어 빠르게 비트맵을 뽑아 여러 기기에 맞게 확인할 수 있어 꽤 도움이 됩니다. 

captureRoboImage(
    filePath = "src/test/snapshots/AccessControl_${stateName}_${device.name}.png"
) {
    val customDensity = Density(
        density = LocalDensity.current.density * displayScale,
        fontScale = fontScale
    )
    CompositionLocalProvider(LocalDensity provides customDensity) {
        UiTestExampleTheme {
            AccessControlContent(
                uiState = uiState,
                onToggleWorkStatus = {},
                onSelectGate = {},
                onRequestAccess = {},
                onDismissDeniedDialog = {},
                onDismissNetworkErrorDialog = {}
            )
        }
    }
}

 

CompositionLocalProvider 를 활용하면 매우 간단하게 폰트 크기와 화면 크기 변경 UI 테스트를 수행할 수 있게 됩니다. 대시민 공공 SI 프로젝트 개발을 주로 하는 제 입장에서는 정말 좋은 테스트 방법입니다.

테스트 앱이라 다 무너지네요.


후기

사실 과거에도 UI 테스트의 필요성이나 의의에 대해 이래 저래 고민도 많이 해보고 관련 포스팅을 쓰기도 해 봤지만 이렇다 할 답을 찾지 못했습니다. 그래서 단순히 인터랙션이 올바르게 동작하는지, 그리고 그 인터랙션으로 상태 변경이 올바르게 반영되는지 정도의 테스트만 해왔고요.

 

Composable 마다 UI 테스트 코드를 작성하는 것은, 제가 못해서 그런 거겠지만 관리하기가 조금 어려웠습니다. 목적을 어떤 것으로 두어야할지도 좀 난해하고, 그렇게 테스트 코드를 작성해도 그것들이 어떤 의미를 갖는지 제 내공이 부족해서, 사실은 여전히 100% 이해를 하고 있지는 않습니다.

 

Roborazzi 의 경우, 픽셀 대조 테스트라는 그 의도가 명확해서 좋았습니다. UI 회귀 테스트라는 목적 자체가 확실시 되니 헤맬 일이 없었습니다. 쉽게 말하면, '이걸 왜 해야 하는지' 가 직관적이어서 빠르게 적용할 수 있었습니다.

 

UI 테스트, 정말 중요합니다. 사실 유닛 테스트의 중요성은 모두가 강조하는데, UI 테스트는 그렇지 않은 것 같긴 하더라고요. 애초에 실물 디바이스나 에뮬레이터로 빠르게 확인할 수 있어서 그렇겠습니다. 다만 저는 색약 때문에 가끔가다 색상 구분 실패로 인한 실수가 나오기도 했어서 이번 Roborazzi 적용의 체감이 더 큰 것 같기는 하네요.

 

아무튼, UI 테스트를 아직 시도해보지 않으신 분들은 Roborazzi 로 한 번 시작해보시는 것을 추천드립니다. 테스트 커버리지를 넘어 앱 완성도를 제고하는 데에 큰 도움이 되리라 믿습니다.

 

긴 글 읽어주셔서 감사합니다.