메인 콘텐츠로 이동하기

스냅샷 테스트 적용기

·7 분
cover
Image generated by Gemini

최근 직접 개발하고 유지관리 하고 있는 gridlayout-compose 라이브러리의 테스트 코드를 스냅샷 테스트로 마이그레이션 했다 (Pull Request). 해당 라이브러리는 Compose Multiplatform용 UI 레이아웃 라이브러리이며 UI 테스트가 곧 라이브러리의 핵심 기능 테스트다.

기존에는 테스트를 위해 Compose UI Test 라이브러리를 사용했다. 이 라이브러리는 Espresso 비슷하게 Compose UI에서 특정 부분을 찾아 논리적으로 검증하는 방식으로 동작한다.

하지만 이 방식에 불편함을 느껴 개선 방법을 찾던 중 스냅샷 테스트라는 것을 발견했고 이 방식으로 마이그레이션 해 보았다. 그 과정에서 여러 가지 방법의 UI 테스트 방법을 찾아보고 생각해보게 되었고 그 경험을 정리해 보고자 한다.

UI 테스트 방법들 #

안드로이드 생태계에서 UI를 테스트하는 방법은 크게 2가지로 나누어 볼 수 있다.

  1. 논리적으로 검증하는 방법

    렌더링된 UI에서 직접 특정 위치의 좌표와 크기, 색상 등을 읽어 의도한 대로 나타났는지 검증하는 방법이 있다. 대부분 기본적으로 제공하는 UI 테스트 라이브러리가 이 방식을 사용한다. 대표적으로 EspressoCompose UI Test 같은 라이브러리가 있다.

    가장 기초적인 방법이고 거의 모든 UI를 테스트할 수 있다. UI를 동작시켜 봐야 하기 때문에 실제 기기나 에뮬레이터가 필요한 경우가 많다.

  2. 이미지 비교를 통해 검증하는 방법

    실제 기기에서 동작시키는 대신 UI만 렌더링해 성공 케이스와 픽셀 단위로 비교하는 방법이 있다. 보통 스냅샷 테스트나 스크린샷 테스트라고 부르는 방식이다. 대표적으로 Paparazzi와 같은 라이브러리가 있다.

    이미지를 렌더링해 비교하기 때문에 굳이 뒷단의 코드까지 실행할 필요가 없다. 때문에 렌더링 엔진만 별도로 사용해 UI를 그려볼 수 있다.

gridlayout-compose가 기존에 사용한 방식은 1번 방식이다. 가장 기초적인 방법이기에 사용하고 있었다. 하지만 점차 테스트할 케이스가 늘어나고 테스트를 추가하고 수정하며 불편함을 느꼈다.

논리적 UI 테스트의 불편한 점 #

@Test
fun testHorizontalGrid_verticalArrangementSpacedBy_adaptive() {
    val itemCount = 3
    val itemSize = 10.dp
    val gridSize = 55.dp
    val spacing = 5.dp

    composeRule.setContent {
        HorizontalGrid(
            rows = SimpleGridCells.Adaptive(itemSize),
            modifier = Modifier.size(gridSize),
            verticalArrangement = Arrangement.spacedBy(spacing),
        ) {
            for (i in 0 until itemCount) {
                Box(
                    modifier = Modifier
                        .testTag(i.toString())
                        .size(itemSize)
                )
            }
        }
    }

    val cellCount = (gridSize + spacing).value.roundToInt() / (itemSize + spacing).value.roundToInt()
    val totalSpacing = spacing * (cellCount - 1)
    val expectedItemSize = (gridSize - totalSpacing) / cellCount
    for (i in 0 until itemCount) {
        composeRule
            .onNode(hasTestTag(i.toString()))
            .assertTopPositionInRootIsEqualTo((expectedItemSize + spacing) * i)
    }
}

이 코드는 실제로 작성했던 테스트 케이스 중 하나이다. HorizontalGridverticalArrangement = Arrangement.spacedBy()를 적용했을 때 그리드의 각 셀이 실제로 주어진 간격만큼 벌어진 채로 배치되었는지 검증하는 코드이다. 이 테스트는 잘 동작하고 있었다. 하지만 불편했던 이유는 무엇일까?

위 테스트 코드는 테스트 타켓 컴포넌트들의 위치가 예상된 대로 배치되었는지 논리적으로 검증한다. 이런 테스트는 실제로 사용하는 방식이다. 하지만 gridlayout-compose 라이브러리는 레이아웃을 제공하는 라이브러리이다. 즉, 위치나 크기 검증 테스트 케이스가 많이 필요하다는 것이다.

논리적인 위치 검증 테스트는 철저하게 수치에 기반해 테스트 할 수 있기 때문에 정확하게 검증할 수 있다는 장점이 있다. 하지만 테스트 케이스의 모습을 UI로 볼 수 없기 때문에 처음 작성할 때나 수정할 때 어려움이 있다. 즉, 유지보수의 어려움이 있다. gridlayout-compose의 경우 이런 테스트가 굉장히 많았다.

스냅샷 테스트의 장점 #

어느 날 스냅샷 테스트(Snapshot Testing)이라는 것이 눈에 들어왔다. UI 테스트의 한 방법이지만 UI의 논리적 값을 검증하는 것이 아니라 렌더링 될 결과물을 이미지 픽셀 단위로 비교하는 방법이다. 이런 방법의 존재 자체는 예전에도 알고 있었지만 사용해 보지는 않았다. 하지만 gridlayout-compose의 테스트의 단점을 해결해 줄 수 있는 방법이라는 생각이 들었다. 그 이유는 아래와 같다.

  1. UI 케이스를 눈으로 볼 수 있다

    기존 테스트에서는 테스트 케이스의 동작 결과를 눈으로 보기 어렵고 머리속으로 상상하는 경우가 많았다. 하지만 스냅샷 테스트는 이상적인 결과의 스크린샷을 이미지로 저장해 두어야 한다. 다시 말해 성공시 어떻게 배치될지를 시각적으로 확인할 수 있다. 테스트가 실패한 경우에도 실패 시의 스크린샷이 제공되기 때문에 쉽게 문제를 파악할 수 있다.

  2. 실제 기기나 에뮬레이터가 필요하지 않다

    이 장점의 경우 사용하는 라이브러리나 플러그인에 따라 다를 수 있다. 하지만 스냅샷 테스트는 UI의 모습을 확인하는 것이 핵심이기 때문에 상당히 많은 경우 실제 기기나 에뮬레이터 없이 렌더링 엔진만을 사용한다. 그 덕분에 무거운 Instrumentation Test 대신 JVM 환경의 Unit Test로 동작할 수 있고, 테스트 환경의 세팅이나 테스트 구동 시간이 크게 줄어든다.

UI 테스트 케이스를 눈으로 볼 수 있기 때문에 유지보수에서 매우 유용하다. 만약 레이아웃을 리팩토링 하다가 문제가 발생하거나 추가적인 기능을 만들 때 스크린샷을 확인할 수 있기 때문에 문제 파악이 용이하다.

테스트가 가볍다는 점도 장점이다. gridlayout-compose는 메인 브랜치나 Pull Request가 생성된 브랜치에서는 푸시가 일어날 때 마다 테스트를 수행하도록 CI를 추가해 두었다. 기존의 테스트는 에뮬레이터 세팅을 위한 actions도 준비해야 하고 무엇보다 테스트 시간이 오래 걸렸다. 스냅샷 테스트로 바꾸면 CI 시간도 크게 줄어들 것이였다.

스냅샷 테스트 적용 #

나의 경우 Paparazzi를 사용하기로 결정했다. Cash App에서 개발한 안드로이드용 스냅샷 테스트 도구이다. Paparazzi는 공식 문서만 따라 해도 금방 적용할 수 있을 정도로 쉽게 만들어져 있다.

이 글은 Paparazzi 2.0.0-alpha01 버전을 기준으로 작성되었다. 다른 버전에서는 적용 방식과 사용법이 다를 수 있다.
plugins {
    alias(libs.plugins.paparazzi)
}

Paparazzi의 적용은 매우 간단했다. Gradle Plugin을 적용하면 해당 모듈에 자동으로 적용된다.

class BoxGridTest {
    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6
    )

    @Test
    fun testDefaultRowColumnPosition() {
        paparazzi.snapshot {
            // 테스트할 UI 코드 작성
        }
    }
}

그리고 테스트 코드에서 Paparazzi Rule을 사용해주면 된다. snapshot 람다에 테스트할 UI 코드를 작성해주면 이후 테스트 시 해당 코드를 렌더링해 이미지를 비교한다.

❯ ./gradlew :recordPaparazziDebug

테스트 케이스를 완성했으면 위의 Gradle Task를 실행해 성공 케이스의 이미지를 렌더링해야 한다. Task를 실행하면 paparazzi.snapshot 람다 내의 코드를 실행해 이미지를 생성한다. 이 이미지들은 성공 케이스로 repository에 함께 올려 두어야 한다.

# 전체 유닛 테스트 동작
❯ ./gradlew :testDebug

# Paparazzi 스냅샷 테스트만 동작
❯ ./gradlew :verifyPaparazziDebug

이후 테스트를 실행할 때 일반적인 유닛 테스트 Task를 실행하거나 Paparazzi의 스냅샷 테스트만 실행할 수 있다.

test-report-example
Paparazzi 테스트 보고서 예시

테스트가 완료되면 각 테스트에 대해서 보고서가 생성되고 각 테스트 케이스의 이미지들을 확인해볼수 있다.

test-failure-example
Paparazzi 테스트 실패 케이스 비교 이미지 예시

실페 케이스의 경우 build/paparazzi/failures/ 경로 아래에 비교 이미지가 생성된다. 저장된 성공 케이스와 실제 렌더링 결과가 다르면 가운데에서 어떤 부분이 다른지 확인해볼 수 있다.

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      # ...
      - name: Run UI tests
        run: ./gradlew :grid:verifyPaparazziDebug

gridlayout-compose의 경우 GitHub Actions를 통해서 푸시 시 자동으로 테스트를 수행한다. GitHub Workflow에서 테스트하는 step을 추가했다. 이로써 코드가 변경될 때 마다 지속적으로 테스트하는 인프라가 만들어졌다.

스냅샷 테스트의 한계점 #

물론 스냅샷 테스트가 UI 테스트의 모든 단점을 해결해주는 은탄환은 아니다.

  1. 성공해야 테스트 케이스를 구현할 수 있다

    스냅샷 테스트의 경우 이미지를 비교하기 때문에 성공 케이스의 이미지를 미리 저장해둘 필요가 있다. Paparazzi의 경우 Gradle Task를 실행하면 지정된 경로에 자동으로 스크린샷을 생성한다. 하지만 실제 코드를 기반으로 스크린샷을 생성하기 때문에 만약 이미 버그가 존재하거나 구현되어 있지 않다면 성공 케이스를 생성할수 없다.

    실제로 스냅샷 테스트로 마이그레이션 하며 존재하는지 몰랐던 버그를 발견해 수정하게 되었다. 이 때문에 스냅샷 테스트는 TDD(테스트 주도 개발)에는 적합하지 않겠다는 생각이 들었다.

  2. 플랫폼별 렌더링 엔진에 따라 미세한 차이가 발생할 수 있다

    gridlayout-compose에서는 발생하지 않은 문제이지만, 실제 기기 또는 에뮬레이터를 활용하지 않고 렌더링 엔진만을 사용하는 스냅샷 테스트 도구에서는 발생할 수 있는 문제이다.

    스냅샷 테스트는 JVM 환경에서 내장된 렌더링 엔진을 활용하게 구현되어 있다. 이 렌더링 엔진은 실제 안드로이드 기기에 들어가는 렌더링 엔진 구현체가 아니라 테스트 환경의 구현체(Linux, macOS, Windows 등)를 따라가게 된다. 대부분은 플랫폼 별 차이가 없도록 구현되어 있지만 폰트 등 일부 경우에는 플랫폼별로 미세하게 차이가 발생할 수도 있다. 스냅샷 테스트를 사용할 때 이런 부분은 감안하고 사용해야 한다.

  3. 논리적 검증을 할 수 없다

    스냅샷 테스트는 UI의 생김새만 검증한다. gridlayout-compose의 경우 UI의 생김새만 검증해도 충분하지만 다른 유즈케이스에서는 충분하지 않을 수 있다.

    예를 들어 특정한 경우 버튼의 클릭 가능 상태가 변하고 실제로 클릭이 불가능한지 검증하려면 스냅샷 테스트로는 검증할 수 없다. 이러한 케이스에서는 스냅샷 테스트가 아니라 Espresso나 Compose UI Test와 같은 논리적 검증을 하는 도구를 써야 한다.

정리 #

gridlayout-compose 라이브러리의 UI 테스트를 스냅샷 테스트로 마이그레이션 하면서 느낀 점을 적어 보았다. 나의 경우 스냅샷 테스트가 매우 효과적이였지만 경우에 따라 스냅샷 테스트와 기존의 테스트를 함께 사용해야 하는 경우도 많을 것이다. 둘의 특징을 비교해 보자면 아래와 같다.

스냅샷 테스트

  • 성공 케이스를 이해하기 쉽다: 코드를 분석하면서 실제 성공 결과가 어떻게 생겼는지 머리속으로 그리는 것 보다 스크린샷을 보면 이해가 쉽다.
  • 논리적 검증이 어려운 부분을 검증할 수 있다: 내 라이브러리의 경우에는 해당하지 않지만, 예를 들어 블러 처리와 같은 부분은 코드로 검증하기 어렵다. 하지만 스크린샷 테스트는 이미지 비교를 통해 검증해볼 수 있다.
  • 테스트 환경이 가볍다: 에뮬레이터나 실제 기기가 필요한 UI 테스트와 달리 JVM 환경에서 유닛 테스트와 같이 동작할 수 있기 때문에 가볍게 동작한다는 장점이 있다.
  • 플랫폼별로 미세하게 차이가 날 수 있다: 렌더링 엔진은 플랫폼마다 미세하게 다르게 처리할 수 있다. 이 때문에 논리적으로 문제가 없어도 환경에 따라 미세한 차이가 발생할 수도 있다.
  • 저장소 용량이 많이 필요하다: 테스트 케이스마다 스크린샷이 있어야 하기 때문에 Repository에 스크린샷을 저장할 용량이 많이 필요하다.

논리적 UI 테스트

  • 실제 환경과 가장 유사하다: 렌더링 엔진만 사용하는 스냅샷 테스트와 달리 실제 기기나 애뮬레이터를 실행하기 때문에 실제 환경과 가장 유사한 환경에서 테스트할 수 있다.
  • 논리적인 검증이 가능하다: 단순히 UI의 생김새만 비교하는 것이 아니라 코드로 검증하기 때문에 스냅샷 테스트로 할 수 없는 논리적인 테스트도 가능하다.
  • 저장소 용량이 적게 필요하다: Repository에 스크린샷을 저장해 두어야 하는 스냅샷 테스트와 달리 테스트 코드만 있으면 된다.
  • 테스트 환경이 무겁다: 테스트를 위해서는 에뮬레이터나 실제 기기가 동작해야 하기 때문에 테스트 환경이 무겁다는 단점이 있다.
  • 복잡한 UI는 성공 케이스를 이해하기 어렵다: 성공 케이스가 눈에 보이는 스냅샷 테스트와 달리 성공적인 경우를 머릿속에서 그려야 하는 경우가 있다.

스냅샷 테스트와 기존의 UI 테스트는 각자 장단점이 있다. 스냅샷 테스트가 모든 경우를 커버해줄 수 없기 때문에 여전히 필요에 따라 Espresso와 같이 UI를 논리적으로 검증하는 방법도 필요하다. 어느 것이 더 우월하지 않고 각자 장단점이 존재하는 만큼 필요한 부분에 적절한 테스트를 활용해 코드의 안정성을 높이는 것이 더 중요할 것이다.