Jetpack Compose와 Flutter는 같은 선언형 프로그래밍 방식으로 UI 코드를 작성하기에, UI 테스트 코드 작성 또한 유사한 방식으로 작동할 줄 알았으나 생각보다 어려운 부분이 많았다.
먼저, Flutter의 위젯 단위는 Widget
이라는 class이다. 따라서 type을 가지기 때문에 UI 테스트 코드에서 특정 타입의 위젯만 골라내는 것이 가능하다.
이하부터 UI Component 단위를 위젯 이라고 부르도록 하겠다.
// basic_page.dart
class BasicPage extends StatelessWidget {
final String? title;
// ...
Widget build(BuildContext context) {
// ...
}
}
// basic_page_test.dart
void main() {
group('BasicPage', () {
testWidgets('Check title', (tester) async {
await tester.pumpWidget(BasicPage(child: Container(),));
await tester.pump(const Duration(milliseconds: 100));
final basicPage = tester.widget<BasicPage>(find.byType(BasicPage));
print("BasicPage title: ${basicPage.title}");
});
}
}
위와 같이 원하는 타입의 위젯을 골라 찾아낼 수 있고, 위젯이 가진 프로퍼티까지 편하게 접근할 수 있다.
하지만 Compose에서 원하는 위젯을 찾아내고 위젯을 구성하는 값을 가져오는 것은 Flutter와는 다른 과정이 필요하다.
Compose의 위젯은 @Composable
함수로 구성한다. 함수이기 때문에 타입을 가지지 않고 프로퍼티 또한 존재하지 않는다.
이러한 Composable 위젯을 테스트 코드에서 접근하는 방법은 Semantics을 활용한다.
Semantics는 Composition에서 접근성(Accessibility)과 테스팅을 구현하기 위해 별도로 구성되는 트리이다.
기본 Compose 위젯(Text
, Image
등)이나 Material library의 위젯들은 몇 가지 기본적인 Semantics를 가지고 있다. 예를 들어 Text
는 text
를, Image
는 contentDescription
를 가진다.
이러한 Semantics를 개발자가 직접 주입해 테스팅에 사용할 수도 있다.
// CustomUi.kt
@Composable
fun CustomUi(modifier = Modifier) {
val borderWidth = if(isFocused || hasError) 1.dp else 0.dp
val borderColor = if(hasError) warningBorder else neutralBorderTertiary
Row(
modifier = modifier
.border(borderWidth, borderColor, RoundedCornerShape(8.dp))
.padding(containerPadding)
.semantics {
testTag = "ContainerModifier"
},
verticalAlignment = Alignment.CenterVertically
) {
// ...
}
}
위 코드를 보면, CustomUi
의 Row
의 Modifier
에 semantics
함수를 사용해 testTag
를 ContainerModifier
로 설정했다.
// CustomUiTest.kt
class CustomUiTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun test() {
with(composeTestRule) {
setContent {
CustomUi()
}
run {
val rowSemantics = onNodeWithTag("ContainerModifier")
rowSemantics.assertExists()
}
}
}
}
테스트 코드에서는 onNodeWithTag
함수로 앞에서 설정한 testTag
를 가진 시멘틱 노드를 찾을 수 있고, 이를 사용해 위젯 존재 여부(assertExists
) 등을 검사할 수 있다.
이렇게 직접 값을 주입하고 위젯을 찾는 방법도 있지만, 특정 텍스트를 포함한 위젯, 스크롤 가능한 위젯 쉽게 찾을 수 있는 여러 방법들도 제공한다.
하지만 Flutter와 같이 type-safe한 방식으로 위젯 혹은 위젯의 속성에 접근하는 방법은 없는듯하여, 위젯의 Modifier에 접근해 border width, padding 값을 가져올 수 없다.
그렇다고 아얘 테스트 코드에서 위젯 속성을 가져올 수 없는건 아니고, 시맨틱 프로퍼티(Semantic Properties)를 활용하면 가능하다.
val borderWidth = if(isFocused || hasError) 1.dp else 0.dp
val borderColor = if(hasError) warningBorder else
Row(
modifier = modifier
.border(borderWidth, borderColor, RoundedCornerShape(8.dp))
shape = RoundedCornerShape(8.dp))
.padding(containerPadding)
.semantics {
testTag = "ContainerModifier"
this[SemanticsPropertyKey(name = "borderWidthKey")] = borderWidth
this[SemanticsPropertyKey(name = "borderColorKey")] = borderColor
},
verticalAlignment = Alignment.CenterVertically
)
semantics
은 키, 값 쌍을 이루는 시맨틱 프로퍼티를 저장할 수 있는 set
함수를 제공하고 [ ]
문법으로 접근할 수 있다. SemanticsPropertyKey
를 사용해 키를 생성해 값을 할당할 수 있다.
위와 같이 borderWidthKey
키에 Modifier
에 설정한 borderWidth
값을 할당하면,
@Test
fun test() {
with(composeTestRule) {
setContent {
CustomUi()
}
run {
val borderWidth = onNodeWithTag("ContainerModifier")
.fetchSemanticsNode()
.config.findLast { it.key.name == "borderWidthKey" }?.value as Dp
assert(borderWidth == 0.dp)
}
}
}
테스트 코드를 보면, 시맨틱 노드에서 fetchSemanticsNode
함수로 SemanticConfiguration
에 접근할 수 있다. 여기서 원하는 시맨틱 프로퍼티를 키 값으로 가져올 수 있다.
borderWidthKey
키로 가져온 borderWidth
를 Dp
로 형변환 해주면 테스트 코드에 사용할 수 있다.
이렇게 Compose에서는 시맨틱을 사용해서 다양한 테스트 코드를 작성할 수 있다. 비록 Flutter 보다는 불편한 부분이 있는건지, 내가 더 효율적인 방법을 찾지 못한 것인지는 모르겠지만 Compose에서도 여러가지 UI 테스트를 수행할 수 있었다.
Semantics in Compose: https://developer.android.com/jetpack/compose/semantics
Testing your Compose layout: https://developer.android.com/jetpack/compose/testing
Testing cheatsheet: https://developer.android.com/jetpack/compose/testing-cheatsheet