Jetpack Compose

[Jetpack Compose] Testing in Jetpack Compose

베르_최성훈 2025. 2. 9. 21:50

들어가기 전에

 테스트에 관련된 글을 쓰는게 정말 오랜만인 것 같네요. 저는 JUnit4, JUnit5, Kotest 를 사용한 단위 테스트, Robolectic 을 사용한 통합 테스트, Espresso 를 사용한 UI 테스트를 작성한 경험이 있습니다. Compose Test 는 최근에 NextStep 학습 테스트로 배우는 Compose 강의를 듣기 시작하면서 접하게 되었습니다.

 

UI 테스트는 여러 버전에서, 다양한 단말기가 문제 없이 의도한 대로 동작하는지 쉽게 테스트 할 수 있습니다. 에러를 쉽게 찾거나 앱의 퀄리티 향상에 도움이 되죠. 

 

 

Key Concepts

Compose 코드 테스트의 핵심 개념은 다음과 같습니다.

  • Semantics: Compose 테스트는 UI 의 각 부분에 각각 의미를 부여하는 semantics 와 상호작용합니다. semantics 는  UI 계층 구조와 함께 생성됩니다.
  • Testing APIs: Compose 는 요소를 찾고(finders), 상태를 검증(Assertion)하며, 버튼 클릭 같은 액션(Actions)을 수행하는 Test API 를 제공합니다.
  • Synchronization: 기본적으로 Compose 테스트는 UI와 자동으로 동기화되어, 어설션을 수행하거나 행동을 실행하기 전에 UI가 idle 상태가 될 때까지 기다립니다.UI가 idle 상태라는 것은 모든 pending 작업이나 애니메이션, recomposition 등이 완료되어 더 이상 변경 사항이 없는 상태를 의미합니다. 
  • Interoperability: 하이브리드 앱에서 Compose 와 View 기반 요소 모두와 상호작용 할 수 있습니다. 또한 다른 테스트 프레임워크와 통합해서 사용할 수 있습니다.

그 중에서 오늘은 Test API 에 대해 좀 더 알아보고 실제로 적용해보겠습니다.

 

 

Compose Testing API

 

앞에서 봤듯 Compose test 에서 UI 요소와 상호작용할 수 있는 방법은 세 가지가 있습니다.

  • Finders : 하나 이상의 요소를 선택해 Assertion 을 만들거나 작업을 실행할 수 있습니다.
  • Assertion: 요소가 존재하는지 혹은 어떤 속성을 가지는지 확인하는데 사용됩니다. 예를 들어 "환영합니다" 라는 텍스트가 존재하는지 확인할 수 있겠죠.
  • Actions: user event 를 요소에 주입합니다. 클릭하거나 제스처를 취하는 등의 시뮬레이션이 가능합니다.

 이 세 가지를 사용하면 "+" 라는 텍스트를 가진 버튼을 찾고(Finders) 버튼을 클릭한 뒤(Actions) count 가 1이 되는지 확인(Assertion)할 수 있겠죠? 그런 방식으로 사용할 수 있습니다. 공식 영상에서 보여준 계산기 예시처럼 1, +, 2, = 을 순서대로 클릭하고 결과가 3이 나오는지 확인할 수도 있습니다.



비밀번호 입력 창 만들기

실제로 어떻게 사용하는지 확인해봅시다. 간단한 요구사항을 만들고 기능이 잘 동작하는지 테스트해보겠습니다.

비밀번호 입력 창을 만든다.
- [ ] 비밀번호는 6자이다.
- [ ] 비밀번호는 숫자만 입력 가능하다. 문자 입력을 제한하지는 않는다.
- [ ] 입력한 비밀번호는 보이지 않아야한다.

 

 

TextField 로 비밀번호 입력 받기

기본 메테리얼 TextField 를 사용해 만들어보았습니다. 먼저 기본적으로 입력 가능하게 만들어 주었습니다.

@Composable
fun PasswordTextField(modifier: Modifier = Modifier) {
    var password by remember { mutableStateOf("") }

    TextField(
        value = password,
        label = { Text("비밀번호 입력") },
        onValueChange = { value -> password = value},
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp)
    )
}

 

 

 

 

비밀번호 가리기

요구사항을 만족할 수 있게 테스트를 먼저 작성해볼까요?

- [ ] 입력한 비밀번호는 보이지 않아야한다.

 

비밀번호는 입력해도 입력된 비밀번호가 보이지 않아야겠죠? 그럼 다음과 같이 테스트를 작성할 수 있습니다.

UI 테스트는 안드로이드 환경에서 동작하기 때문에 AndroidTest 패키지에 만들어주어야 합니다!
class PasswordTextFieldTest {
    // JUnit Rule 로 컴포즈 테스트 환경 구성
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun 비밀번호를_입력해도_비밀번호가_보이지_않는다() {
        val password = "123456"

        // 테스트 중 렌더링 할 Compose UI
        composeTestRule.setContent {
            MaterialTheme {
                PasswordTextField()
            }
        }

        composeTestRule
            .onNodeWithText("비밀번호 입력") // 텍스트로 비밀번호 UI 노드를 찾는다.
            .performTextInput(password) // 비밀번호를 입력한다.

        composeTestRule
            .onNodeWithText(password)
            .assertDoesNotExist() // 비밀번호가 보이는지 확인한다.
    }
}

 

테스트를 실행해보면 바로... 실패! PasswordTextField 를 수정해주지 않았기 때문이죠.

 

 

 

visualTransformation 인자로 PasswordVisualTransformation 을 객체를 전달하고 다시 실행시켜보겠습니다.

@Composable
fun PasswordTextField(modifier: Modifier = Modifier) {
    var password by remember { mutableStateOf("") }

    TextField(
        value = password,
        label = { Text("비밀번호 입력") },
        onValueChange = { value -> password = value },
        visualTransformation = PasswordVisualTransformation(), // 추가
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp)
    )
}

 

실행 결과.. 성공! 이런식으로 Composable 함수를 테스트 할 수 있습니다.

 

 

 

 

비밀번호 유효성 검사하기

남은 두 가지 요구사항에 대해서도 테스트를 해봅시다!

- [ ] 비밀번호는 6자이다.
- [ ] 비밀번호는 숫자만 입력 가능하다. 문자 입력을 제한하지는 않는다.

 

테스트는 다음 세 가지에 대해 작성하였습니다. 실제 코드라면 범위에 걸리는 것들에 대해 조금 더 많은 경우의 수를 테스트 해야겠지만 줄였습니다.

 

1. 6자리가 아닌 경우

2. 문자가 포함된 경우

3. 올바르게 입력된 경우

@Test
    fun 비밀번호_길이가_6자리가_아니면_에러_메시지가_출력된다() {
        val invalidPassword = "12345" // 5자리로, 6자리가 아님

        composeTestRule.setContent {
            MaterialTheme {
                PasswordTextField()
            }
        }

        // 비밀번호 입력란을 찾아 잘못된 비밀번호를 입력
        composeTestRule
            .onNodeWithText("비밀번호 입력")
            .performTextInput(invalidPassword)

        // 유효하지 않으므로 에러 메시지가 표시되어야 한다.
        composeTestRule
            .onNodeWithText("비밀번호는 6자리 숫자를 입력해야 합니다.")
            .assertExists()
    }

    @Test
    fun 비밀번호에_숫자외_문자가_포함되면_에러_메시지가_출력된다() {
        val invalidPassword = "12345a" // 6자리이지만 숫자 외 문자가 포함됨

        composeTestRule.setContent {
            MaterialTheme {
                PasswordTextField()
            }
        }

        // 비밀번호 입력란을 찾아 잘못된 비밀번호를 입력
        composeTestRule
            .onNodeWithText("비밀번호 입력")
            .performTextInput(invalidPassword)

        // 에러 메시지가 나타나야 한다.
        composeTestRule
            .onNodeWithText("비밀번호는 6자리 숫자를 입력해야 합니다.")
            .assertExists()
    }

    @Test
    fun 올바른_비밀번호_입력시_에러_메시지가_보이지_않는다() {
        val validPassword = "123456" // 6자리 숫자

        composeTestRule.setContent {
            MaterialTheme {
                PasswordTextField()
            }
        }

        // 올바른 비밀번호를 입력한다.
        composeTestRule
            .onNodeWithText("비밀번호 입력")
            .performTextInput(validPassword)

        // 에러 메시지가 없어야 한다.
        composeTestRule
            .onNodeWithText("비밀번호는 6자리 숫자를 입력해야 합니다.")
            .assertDoesNotExist()
    }

 

 

값이 입력되면 password 를 검증하고 유효하지 않은 경우에 ErrorMessage 를 보여줬습니다.

 

@Composable
fun PasswordTextField(modifier: Modifier = Modifier) {
    var password by remember { mutableStateOf("") }
    var passwordError by remember { mutableStateOf("") }

    TextField(
        value = password,
        label = { Text("비밀번호 입력") },
        onValueChange = { value ->
            password = value
            val isValid = password.length == 6 && password.all { it.isDigit() }
            passwordError = if (!isValid) "비밀번호는 6자리 숫자를 입력해야 합니다." else ""
        },
        visualTransformation = PasswordVisualTransformation(), // 추가
        isError = passwordError.isNotBlank(),
        supportingText = { Text(passwordError) },
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp)
    )
}

 

테스트가 모두 성공하는 것을 확인할 수 있습니다:)

 

 

 

 

UI 테스트의 WOW 포인트

굉장히 빠른 속도로 테스트가 실행되는 것을 확인할 수 있습니다. 기존 기능이 제대로 동작하는지, 새로운 기능에 문제 없는지 쉽고 빠르게 확인할 수 있어 매력적이었습니다. 끝!

 

 

출처

https://developer.android.com/develop/ui/compose/testing

https://developer.android.com/develop/ui/compose/testing/apis