Android 개발/Jetpack Compose

안드로이드 Compose 테스트 컴파일 에러, Matcher 기반 마이그레이션 일지

stackD 2026. 5. 8. 18:00

 

월요일 아침이었어요. 평소처럼 안드로이드 스튜디오(Android Studio)를 켰는데, 수십 개의 테스트 파일이 온통 빨갛게 물들어 있더라구요. 별생각 없이 올린 컴포즈(Compose) 버전 하나가 원인이었습니다.

 

진짜 멘붕이 오더라구요. 잘 돌아가던 테스트가 한순간에 전부 컴파일 에러를 뱉어내니까요. 저처럼 갑작스럽게 빨간 테스트 코드 더미를 마주한 분들이 분명 계실 것 같아서, 해결 과정과 그 속에서 배운 점들을 정리해봤습니다.

 

기존 테스트 코드가 한계에 부딪힌 이유

원인은 테스트 환경의 고도화와 관련이 있었어요. 사실 젯팩 컴포즈(Jetpack Compose) 최신 버전에서 onNodeWithText 같은 확장 함수 자체가 공식적으로 완전히 삭제된 건 아니에요. 하지만 테스트 요구사항이 복잡해지면서, 기존에 습관처럼 쓰던 단순한 단일 조건 탐색 방식으로는 한계가 명확해졌거든요.

 

특정 컴파일 에러들은 구형 API나 디프리케이트(deprecated)된 제스처 API(예: performGesture가 performTouchInput으로 대체된 것)를 함께 걷어내고 최신 환경에 맞추려다 터진 것들이 많았습니다.

 

결과적으로 이참에 테스트 코드를 더 견고하게 만들기 위해 선택한 핵심 마이그레이션 방향은 '찾는 행위(Finder)'와 '조건(Matcher)'의 명확한 분리였습니다.

  • 기존의 단순 탐색: onNodeWithText("버튼")
  • 개선된 조건 조합: onNode(hasText("버튼"))

과거에는 "버튼이라는 텍스트를 가진 노드를 찾는다"며 탐색 행위와 조건이 하나에 뭉뚱그려져 있었어요. 반면에 개선된 방식에서는 onNode라는 '찾는 행위'와 hasText("버튼")이라는 구체적인 '조건'을 명확하게 분리했습니다.

 

이런 구조로 패러다임을 바꾸니 테스트 코드의 유연성과 조합성이 훨씬 좋아지더라구요. hasText()나 hasTestTag() 같은 SemanticsMatcher를 and(), or() 로 조합해서 더 복잡하고 구체적인 조건을 쉽게 만들 수 있게 된 거거든요.

 

 

명시적 조건으로 옮기는 핵심 변환 규칙

처음엔 막막했는데, 다행히 대부분은 기계적인 치환이 가능했어요. 당장 급한 불을 끄고 코드를 더 깔끔하게 다듬고 싶다면 아래 세 가지만 기억하셔도 됩니다.

 

1. 기본적인 1:1 변환 가장 많이 쓰이는 편의성 함수들은 명시적 조건(Matcher)으로 간단하게 바꿀 수 있습니다.

  • onNodeWithText("text") → onNode(hasText("text"))
  • onNodeWithTag("tag") → onNode(hasTestTag("tag"))
  • onNodeWithContentDescription("label") → onNode(hasContentDescription("label"))

onNodeWith... 로 시작하던 걸 onNode(has...()) 형태로 바꾼다고 생각하시면 편해요. 찾는 행위와 조건이 분리됐다는 것만 이해하면 금방 익숙해지더라구요.

 

2. 여러 노드를 찾을 때 여러 노드를 한 번에 찾아야 할 때 쓰는 onAllNodes 도 마찬가지예요.

  • onAllNodesWithText("text") → onAllNodes(hasText("text"))

onAllNodes 는 여러 개의 노드를 다루는 SemanticsNodeInteractionCollection 을 반환한다는 점, 그리고 여기서 특정 노드를 뽑아 쓰려면 .onFirst() 나 인덱스([index])를 활용해야 한다는 점만 기억해두시면 됩니다.

 

3. 디버깅 시간을 줄여주는 한 줄 조건(Matcher)이 왜 실패하는지, 내가 찾는 노드의 시맨틱 속성이 대체 뭔지 헷갈릴 때가 진짜 많거든요. 그럴 땐 이 코드를 꼭 써보세요.

composeTestRule.onRoot().printToLog("SemanticsTree")

 

현재 화면의 전체 시맨틱 트리를 Logcat에 예쁘게 찍어주는 한 줄입니다. 이걸로 실제 트리 구조를 눈으로 확인하면서 조건을 작성하면 디버깅 시간이 절반 가까이 줄어들기도 해요.

 

 

Compose 테스트 품질까지 끌어올리는 활용법

코드를 수정하다 보니 이게 단순히 귀찮은 마이그레이션 작업이 아니라는 생각이 들더라구요. 오히려 더 좋은 테스트 코드를 작성할 기회였습니다.

 

1. 숨어있는 노드까지 테스트하기

useUnmergedTree 옵션을 아시나요? 컴포즈는 접근성(Accessibility)과 테스트 성능을 위해 의미상 합칠 수 있는 UI 요소들을 자동으로 병합하는데요. 이 옵션을 true 로 주면 병합되기 전의 원본 트리(Unmerged Tree)를 기준으로 노드를 검색할 수 있습니다.

// 병합된 트리에서는 못 찾을 수도 있는 내부 Text 노드까지 찾을 수 있음
onNode(hasText("내부 텍스트"), useUnmergedTree = true)

 

이걸 쓰면 디자인 시스템 컴포넌 내부의 아주 세밀한 부분이나 접근성 처리 전의 구조까지 꼼꼼하게 검증하는 테스트를 만들 수 있더라구요.

 

2. UI 상태까지 정확하게 검증하기

조합형 API의 진짜 강력함은 조건을 조합할 때 드러납니다. 단순히 '로그인'이라는 텍스트가 있는지를 넘어, '로그인 버튼이 비활성화된 상태' 같은 구체적인 UI 상태까지 검증할 수 있거든요.

// '로그인' 텍스트를 가졌고, 비활성화(isEnabled가 false) 상태인 노드를 찾는다
onNode(hasText("로그인") and isNotEnabled())
    .assertIsDisplayed()

 

이렇게 하면 사용자 입력값이 부족해서 버튼이 비활성화되어야 하는 시나리오 같은 걸 아주 명확하게 테스트할 수 있어요.

 

3. 비즈니스 의미를 직접 테스트하기

더 나아가면 커스텀 시맨틱 속성(Custom Semantics Property)을 활용할 수도 있어요. 예를 들어 우리 앱에서 '핵심 액션 버튼'을 나타내는 특별한 스타일이 있다고 해볼게요.

val IsPrimaryButtonKey = SemanticsPropertyKey<Boolean>("IsPrimaryButton")
var SemanticsPropertyReceiver.isPrimaryButton by IsPrimaryButtonKey

// Composable에 커스텀 속성 부여
Modifier.semantics { isPrimaryButton = true }

// 테스트 코드에서 해당 속성으로 노드 찾기
onNode(SemanticsMatcher.expectValue(IsPrimaryButtonKey, true))
    .performClick()

 

이제 테스트 코드는 버튼의 색깔이나 텍스트 같은 구현 디테일에서 완전히 벗어났네요. "이 화면의 주요 액션 버튼을 클릭한다"라는 비즈니스 로직 그 자체를 테스트하게 된 거거든요. UI가 어떻게 변하든 시맨틱 의미만 유지되면 테스트가 깨지지 않으니 유지보수성이 정말 좋아집니다.

 

 

도구가 모든 걸 해결해주진 않습니다

물론 장점만 있는 건 아니에요. SemanticsMatcher 나 시맨틱 트리 개념은 처음 접하면 익숙해지는 데 시간이 걸릴 뿐 아니라, 이런 API 최신화가 비동기 테스트의 불안정성(Flaky Test)까지 완벽하게 잡아주진 않습니다.

 

혹시 테스트 코드에 Thread.sleep() 같은 코드를 쓰고 계셨다면, 이건 무조건 빼셔야 해요! 네트워크 통신 같은 무거운 백그라운드 작업을 기다릴 때는 여전히 IdlingResource를 등록하는 게 정석이지만, 단순히 UI 상태가 화면에 반영되는 걸 기다리는 거라면 요즘 컴포즈 환경에서는 훨씬 찰떡인 대안들이 있거든요.

 

컴포즈 테스트는 기본적으로 waitForIdle()을 통해 프레임워크 단에서 UI 렌더링이 유휴 상태가 될 때까지 알아서 기다려주기 때문에 많은 경우 추가 설정이 필요 없어요. 여기에 특정 컴포넌트나 데이터가 화면에 나타날 때까지 기다려야 한다면 waitUntil 같은 API를 쓰는 게 훨씬 가볍고 안정적입니다.

 

특히 최신 컴포즈 테스트 API에서는 waitUntilExactlyOneExists나 waitUntilNodeCount 같은 더 세밀한 함수들도 지원해서 한결 편해졌거든요. 애니메이션 같은 시간을 다루는 테스트라면 mainClock.advanceTimeBy()를 활용해 시간을 마음대로 컨트롤하는 방법도 정말 유용하고요. 결국 도구가 좋아져도 그걸 상황에 맞게 잘 조합해서 쓰는 건 개발자의 몫인 것 같습니다.

마치며

이번 마이그레이션 경험은 처음엔 좀 힘들었지만, 덕분에 컴포즈 테스트의 동작 원리를 더 깊게 이해하게 됐어요. 단순히 화면에 특정 요소가 '보인다'는 걸 넘어, '어떤 의미와 상태를 가지고' 보이는지를 검증하는 방향으로 코드를 고도화할 수 있어서 진짜 마음에 들었습니다.

 

혹시 지금 빨갛게 변한 테스트 코드 앞에서 막막함을 느끼고 계신다면, 이번 기회를 단순히 에러 지우기로만 흘려보내지 말고 내 테스트 코드의 품질을 한 단계 끌어올리는 계기로 삼아보시면 좋겠습니다. 당장은 편의성 함수들을 명시적 조건으로 기계적으로 바꾸는 데서 시작하더라도, 익숙해지면 조건 조합이나 커스텀 시맨틱 속성까지 차근차근 넓혀가시라고 꼭 추천해 드리고 싶네요!