동기
최근, 성장이 더딘 느낌을 받고 있습니다. 지식을 습득하고 쌓는 일은 소홀히 하지 않고 있지만, '내가 개발한 애플리케이션이 과연 훌륭한 애플리케이션인가' 라는 의문을 지울 수 없습니다.
좋은 애플리케이션은 견고한 구조, 그 이상의 추상적인 의의가 필요하다고 생각합니다. 저는 그것이 '좋은 유지보수성' 과 '테스트 용이성' 이라고 생각합니다. 이를 위해서는 테스트 코드가 필수적이라고 할 수 있습니다.
유닛 테스트를 작성하는 것에는 업무를 통해 꽤나 익숙해졌지만, 여전히 UI 테스트에 대한 두려움이 남아 있었고, 이제는 이에 도전하여 한 걸음 더 성장하는 것이 필요한 시점이라고 느꼈습니다.
오늘은 사내에서 개발한 라이브러리가 적용된 샘플 애플리케이션을 개발하며 UI 테스트에 도전해보고, 이에 관한 내용을 포스팅합니다.
Jetpack Compose UI 테스트 작성 방법
유닛 테스트만 작성해봤지, 계측 테스트(InstrumentedTest)는 작성해 본 적이 없어서 어떻게 시작해야 할지 난감했습니다. 일단 유닛 테스트 시 사용했던 Kotest 는 사용하지 못하기에, JUnit 을 사용하는 방식으로 진행하였습니다. BDD 방식으로 작성할 수는 있지만, IDE 에서 구분해주지 않아 불편함은 있겠네요.
@get:Rule
val composeTestRule = createComposeRule()
가장 먼저, createComposeRule() 메서드를 통해 ComposeContentTestRule 을 생성합니다.
@get:Rule Annotation 을 통해 테스트 환경을 구성하는 코드가 생성되기 때문에, 별도의 가시성 제한자 사용 없이 선언합니다.
private val viewModel = MainViewModel()
@Before
fun setUp() {
composeTestRule.setContent {
MainScreen(viewModel = viewModel)
}
}
테스트하고자 하는 Composable 을 선언해줍니다. 저는 화면 단위로 테스트 코드를 작성했기 때문에, 화면 전체를 선언했습니다. UI 테스트 코드지만 ViewModel 은 직접 객체를 생성해서 사용합니다. 테스트 대상이 UI 코드이긴 하지만, UDF(Unidirectonal Data Flow) 구조를 채택하였기 때문에, 정상적인 동작을 위해서는 ViewModel 의 역할이 가장 중요하다고 볼 수 있습니다.
모킹을 위해는 MockK 를 사용했는데, 이는 추후 UseCase 나 DataSource 를 주입해야 하는 상황이 발생하면 사용하게 됩니다.
다양한 모킹 라이브러리가 존재하지만, 그 중에서 MockK 를 선택한 이유는, Kotlin 친화적이라는 점과 간편한 사전 준비 과정에 있습니다. 다른 라이브러리(PowerMock, Mockito) 등은 ViewModel 만 모킹하려 해도 준비해야 할 것이 많았는데, MockK 는 비교적 간편했습니다.
composeTestRule
.onNodeWithTag("ScannerButton")
.assertExists()
.performClick()
모든 준비가 끝났으니, 테스트 하고자 하는 Composable 에 접근, 테스트 코드를 작성하면 되겠습니다. Composable 에 접근하는 방식도 여러가지가 있는데, 별도의 태그를 붙여 접근하는 편이 가장 간단해 보였습니다. 태그는 Modifier.testTag() 메서드를 통해 지정할 수 있습니다.
@Stable
fun Modifier.testTag(tag: String) = this then TestTagElement(tag)
무엇을 테스트하여야 하는가?
실무에서 테스트 코드를 작성해 본 경험이 없었기 때문에, '무엇을 테스트해야 하는가?' 라는 고민이 뒤따랐습니다. 한 번이라도 작성해봤으면 모르겠는데, 생판 처음이니 뭘 테스트해야 하는지도 알지 못했죠.
숱한 고민 끝에, 본질에 집중하기로 했습니다. 그 결과, 테스트 대상은 '특정 액션을 통해 상태의 변화가 이루어지는 인과관계' 로 설정하였고, 이를 테스트하기 위해서는 화면에 Composable 이 표시되는지, 그리고 클릭 이벤트가 존재하는지, 존재한다면 클릭 이벤트를 트리거하는 것이 필요했습니다.
이를 바탕으로 다음과 같은 테스트 코드를 작성할 수 있습니다.
@Test
fun testScannerButtonClick() {
composeTestRule
.onNodeWithTag("ScannerButton")
.assertExists()
.performClick()
assertEquals(true, viewModel.uiState.value.isScanner)
assertEquals(false, viewModel.uiState.value.isGenerator)
composeTestRule
.onNodeWithTag("CurrentScreenText")
.assertTextEquals("Scanner")
}
@Test
fun testGeneratorButtonClick() {
composeTestRule
.onNodeWithTag("GeneratorButton")
.assertExists()
.performClick()
assertEquals(false, viewModel.uiState.value.isScanner)
assertEquals(true, viewModel.uiState.value.isGenerator)
composeTestRule
.onNodeWithTag("CurrentScreenText")
.assertTextEquals("Generator")
}
클릭 이벤트를 실행하면 ViewModel 의 Reducer 를 통해서 UiState 의 업데이트가 이루어지고, 이를 수집하고 있는 UI 수준에서 Recomposition 을 트리거합니다. 변경되는 데이터에 따라 UI 가 서로 다른 문자열을 표시하게 되므로, 이를 검증하는 테스트 코드를 작성하였습니다.
후기
UI 테스트 코드를 처음 작성해 보고 느낀 점은 다음과 같습니다.
첫 째로, 'UiEffect 를 처리하는 코드에 대한 테스트가 까다롭다.' 입니다.
싱글 스레드 환경에서 테스트가 진행되다 보니, 다양한 객체들이 협력하며, 그 와중에 viewModelScope 가 돌아가고, Composable 내에서도 또 다른 CoroutineScope 가 동작하는 등, 복잡하게 구조화된 일련의 과정을 매끄럽게 테스트하기가 여간 어려운 일이 아닌 것 같습니다. 그러다보니, 이벤트를 UiState 로 처리하는 방식에 대해 탐구해 볼 필요가 있다는 생각도 들었습니다.
둘 째로, 'Composable 구현 시, 테스트 용이한 형태로 작성해야 한다.' 입니다.
현재는 화면 단위로 UI 테스트 코드를 작성하고 있지만, Composable 단위로 분리하여 조금 더 미시적인 관점에서의 테스트를 진행하는 것도 필요해 보입니다. 이러한 과정을 위해서 Composable 들을 분리시켜 구현해 둘 필요가 있습니다.
마지막으로, '테스트 대상을 명확히 설정하여야 한다.' 입니다.
어떤 것을 테스트해야 하는지 깊은 고민이 분명히 필요합니다. 단순한 표시 여부나 클릭 이벤트 존재 여부는 상황에 따라 큰 의미가 없을 수 있습니다. 어떤 액션이 어떤 결과를 도출해내는지에 대한 테스트를 기본으로 함이 좋고, 결정적으로 '무엇을' 테스트 해야 하는지에 대한 포커싱이 요구됩니다.
결국 UI 는 화면에 보여지는 것이고, 화면에 보여지는 것과의 상호 작용 중 가장 비중이 높은 것은 터치입니다.(클릭 이벤트) 가장 본질적인 것에 집중하다 보면, 부수적인 것들은 자연스레 따라오기 마련입니다.
더 많이, 더 자주 UI 테스트 코드를 작성해나간다면, 아마도 더 나은 테스트 코드를 작성할 수 있지 않을까 싶습니다.
'Android > Tech' 카테고리의 다른 글
UiEffect 를 위한 Channel<T> 사용법 (0) | 2025.01.15 |
---|---|
안드로이드의 MVI 와 Reducer (0) | 2025.01.15 |
[Jetpack Compose] composed {} 와 Modifier.Node 로 Modifier Chain 최적화하기 (0) | 2025.01.15 |
LazyList 과 RecyclerView 의 메커니즘 알아보기 (0) | 2025.01.13 |
[Jetpack Compose] CompositionLocal 로 이벤트 처리하기 (0) | 2024.02.29 |