
Appium Flutter Driver 다시 (powered by Claude Code)
요약
Appium Flutter Driver의 실운영 환경 문제를 해결하기 위한 개량 과정을 다룹니다. Claude Code를 활용하여 Dart, JS/React, TypeScript 기반의 3개 리포지토리를 수정하고, Widget key 처리 및 Appium Inspector와의 호환성 문제를 개선했습니다.
핵심 포인트
- Claude Code를 활용한 멀티 리포지토리 소스 코드 수정
- Flutter Widget의 key가 문자열 'null'로 출력되는 버그 해결
- Appium Inspector의 신규 기능 'Tap By Element' 지원 및 동기화
- 모호성 해소 메뉴 사라짐 및 오버레이 위젯 탭 이슈 개선
1. 서론
이전에 「Appium Flutter Driver가 Appium에서 동작하도록 개량해 보았다」라는 기사를 쓴 적이 있다. 좌표 지정으로만 요소를 조작할 수 있었던 Flutter 앱의 E2E 테스트를, Widget 단위(ByType, ByValueKey, ByTooltipMessage 등)로 조작할 수 있도록 하고, appium-handler라는 독자적인 컴포넌트를 신규로 만들었다는 내용이었다.
그로부터 실제로 프로덕트의 E2E 테스트로서 일상적으로 돌려보니, 동작은 하지만 실운영을 견디기 어려운 부분들이 여러 군데 나타났다. 테스트 도중에 컨텍스트 메뉴(Context Menu)가 사라지거나, 가끔 탭(Tap)이 좌표 지정으로 폴백(Fallback)되거나, Widget에 key를 달지 않았다는 이유만으로 요소를 찾지 못하는 등의 상황이었다.
게다가 그사이 Appium Inspector 본가에도 움직임이 있었다. 요소 하이라이터를 직접 클릭하여 탭하는 「Tap By Element」라는 새로운 인터랙션 모드가 추가되었다 (appium/appium-inspector#3039). 기왕 이렇게 된 거 포크(Fork)를 동기화하고, Flutter 세션에서도 이 신기능을 사용할 수 있도록 손을 보았다.
이번에는 그 일련의 개수 기록이다. appium-handler (Dart), appium-inspector (JS/React), appium-flutter-driver (TypeScript)의 3개 리포지토리에 걸친 이야기다.
또한, 이번에는 지난 기사를 썼을 때는 없었던 「Claude Code」를 사용하여 소스 코드를 수정하도록 했다.
2. 배경
지난 기사에서 만든 메커니즘은 말하자면 「한 번 동작시킨」 단계의 것이었다. 실제로 매일의 CI나 로컬 테스트 기록에 사용하기 시작하자, 다음과 같은 문제들이 표면화되었다.
- Widget에 key를 설정하지 않으면, appium-handler가 출력하는 XML의 key 속성이 문자열 "null"이 되어, byValueKey를 통한 검색이 의도치 않게 성립되거나 (반대로 거부되거나) 한다.
- Appium Inspector의 우클릭 메뉴(모호성 해소 메뉴)가 3초 간격의 화면 자동 갱신 타이밍에 자주 사라진다.
- 후보가 많은 화면에서는 메뉴를 스크롤할 수 없어 모든 항목을 다 볼 수 없다.
- 코치마크(Coach Mark)와 같은 범용적인 오버레이(Overlay) Widget을 탭하면, 목표한 요소가 아니라 좌표 지정이 되어버리는 경우가 있다.
- Flutter 세션에서 「Web/Hybrid App Mode」 버튼을 누르면 에러가 발생한다.
하나하나가 사소하지만, 일상적으로 테스트를 작성하거나 기록하는 사람에게는 은근히 영향을 미치는 버그들뿐이었다. 순서대로 해결해 나간다.
3. 개량점
3.1 key가 문자열 "null"이 되는 문제
appium-handler (Dart 측)는 Flutter Inspector의 diagnostic properties를 순회하며 XML 형식의 Page Source를 구성하고 있다. Widget에 key가 설정되어 있으면 그 값을 key 속성으로서 XML에 써 내려가는 설계다.
문제는 key를 가지지 않은 Widget의 경우였다. diagnostic properties에 key 엔트리 자체가 존재하지 않는 케이스가 있었고, 이때 로컬 변수 key는 Dart의 null 값인 상태로 key="$key"라는 문자열 보간(String Interpolation)에 전달되었다. 결과적으로 XML 상에는 리터럴 문자열 `
가장 애를 먹었던 부분이 이것이다. 모호성 해소 메뉴(여러 Widget이 겹쳐 있는 곳을 우클릭하면 후보 목록이 나오는 메뉴)가, Appium Inspector의 자동 리프레시(3초마다 스크린샷과 Page Source를 다시 가져오는 처리, 이번 개정에서 추가) 타이밍에 가끔 사라져 버린다.
원인 파악을 위해 5번 정도 시행착오를 겪었다.
- Redux의 reducer가 리프레시 획득에 실패한 타이밍에 screenshot을 null로 되돌리고 있었다 → 수정했으나 재현 현상이 멈추지 않음
- 스크린샷의 표시 조건에 screenshotError가 얽혀 있었다 → 이것도 고쳤지만 불충분함
- 스크린샷의 스케일 변경을 알리기 위해
window.dispatchEvent(new Event('resize'))를 호출하던 코드가, antd의 Dropdown 내부 구현(@rc-component/trigger의 useWatch 훅)에 의해 중간에 가로채져, window의 resize 이벤트가 오면 묻지도 따지지도 않고 팝업을 닫는 사양에 걸려 있었다 → 독자적인 이벤트로 교체했으나 여전히 사라짐 - 메뉴 표시 중에는 자동 리프레시의 interval 자체를 멈추는 접근 방식도 시도해 보았으나, 그럼에도 사라진다는 보고가 있음
결국, antd의 Dropdown / Menu를 컨텍스트 메뉴의 트리거로 사용하는 한, trigger={['contextMenu']}가 내부적으로 alignPoint와 clickToHide를 강제로 true로 만들어 버리며, 그로부터 이어지는 @rc-component/trigger의 resize 감시 프로세스를 완전히 제어할 수 없다는 결론에 도달했다. 5번째 대책으로 antd 컴포넌트를 사용하는 것을 그만두고, position: fixed로 좌표를 직접 계산하는 독자적인 메뉴로 통째로 교체했다. 개폐 상태도 React의 로컬 state로 완전히 직접 관리하고 있다.
여담이지만, 이 수정을 적용한 후에도 "여전히 사라진다"는 보고가 한 번 있었는데, 원인을 찾아보니 브라우저 개발자 도구의 캐시가 이전 빌드를 잡고 있었다는 허무한 결말이었다. 캐시 삭제와 새로고침으로 해결되었다. UI 버그 조사 시 흔히 발생하는 일이라는 느낌이다.
3.3 후보가 많은 메뉴에 스크롤 추가
요소가 밀집된 화면에서는 모호성 해소 메뉴의 후보가 10개를 넘는 경우도 있다. 화면 밖으로 벗어나 전부 보이지 않는 문제가 있었기에, 메뉴에 max-height: calc(100vh - 16px); overflow-y: auto;를 추가하여 스크롤이 가능하도록 했다.
이것으로 해결된 줄 알았으나 부작용이 발생했다. CSS 사양상 overflow-y: auto를 지정하면 overflow-x도 암묵적으로 auto 취급이 된다. 모호성 해소 메뉴의 각 후보에는 추가적인 서브메뉴(해당 Widget에 대한 액션 목록)가 있으며, position: absolute; left: 100%로 오른쪽으로 삐져나오는 형태로 표시하고 있었는데, 이것이 가로 방향으로 클리핑(clip)되어 보이지 않게 되었다.
서브메뉴를 메뉴의 자식 요소가 아니라, position: fixed인 형제 요소로 분리하고, 호버 시 getBoundingClientRect()로 좌표를 계산하여 인라인 스타일로 배치하는 방식으로 변경하여 해결했다. CSS의 overflow 제어와 position:absolute의 조합은 이런 부분에서 예상치 못한 부작용을 낳는다.
3.4 모호성 해소 메뉴에 "탭하기" 추가
원래 이 메뉴에는 요소의 존재 확인이나 텍스트 확인 액션밖에 없었다. 실제로 테스트를 작성하다 보면 확인뿐만 아니라 "이 후보를 선택해서 탭하고 싶다"는 상황이 압도적으로 많다.
appium-handler 측의 _performActions에 case 'tap':을 추가하여, elementId가 지정되어 있으면 해당 요소를 핀포인트로 탭할 수 있도록 했다. Appium Inspector 측에도 tapElementAtCoordinates 액션을 추가하여 메뉴의 맨 위에 "탭하기"를 나오게 하고 있다. 기록 중이라면, 해결된 Flutter finder 정보(tooltip, semanticLabel, key, text 중 하나)를 그대로 테스트 코드에 남길 수 있다.
3.5 Flutter 세션 특유의 에러 해결하기
「Web/Hybrid App Mode」 버튼을 Flutter 세션에서 눌렀을 때 execute/sync 관련 에러가 발생하는 버그가 있었다. 원인은 handleRefresh가 appMode가 WEB_HYBRID일 때 무조건 WebView용 HTML 태그 부착 스크립트(getContextUpdate)를 실행했기 때문이었다. Flutter 세션의 경우 이 처리 자체가 불필요하고 오류를 일으키므로, isFlutterSession() 체크를 추가하여 그대로 통과하도록 수정했다.
비슷한 사례로, 세션 시작 직후의 표시가 실제 컨텍스트(FLUTTER)와 일치하지 않고 「Native App Mode」라고 표시되는 문제도 있었다. 이는 SessionBuilder.js의 newSession이 browserName이 없으면 무조건 Native 모드로 취급했기 때문이었다. automationName이 flutter인 경우 WEB_HYBRID로 취급하는 분기문을 추가하여 수정했다.
3.6 탭(Tap)이 좌표 지정으로 되어버리는 문제
가끔 의도한 요소가 아니라 좌표 기반의 탭이 발생하는 현상이 있었다. 조사해 본 결과 원인은 두 가지였다.
하나는 실제로 존재하는 버그다. getPageSource를 호출할 때마다 inspector-N이라는 일련번호 ID를 새로 부여하기 때문에, 리프레시 전후로 이 ID가 우연히 다른 Widget과 일치하는 경우가 있었다. 요소를 해결하는 _resolveNode에 ID뿐만 아니라 bounds(좌표 범위) 포함 여부 체크를 추가하여, ID가 일치하더라도 bounds가 맞지 않으면 "찾지 못함"으로 처리하도록 수정했다.
다른 하나는 조사 결과 버그가 아니라고 판단한 케이스다. 코치 마크(Coach mark) 오버레이처럼 Stack/Container/Row와 같은 범용적인 타입이면서, tooltip도 semanticLabel도 key도 text도 가지고 있지 않고 타입의 인덱스조차 불안정한 Widget은, appium-handler의 검색 로직(_execCommandWithFinderChain)이 최후의 수단으로 의도적으로 좌표 기반 조작으로 폴백(Fallback)하도록 설계되어 있다. 이는 "찾을 방법이 없으니 좌표로 타협한다"는 설계상의 결정이며, 고칠 수 있는 부분이 아니다. 회피책으로는 우클릭 모호성 해소 메뉴를 통해 실제로 타겟팅하고자 하는 의미 있는 요소(코치 마크 아래에 있는 실제 버튼 등)를 선택하여 탭하는 방법을 안내하고 있다.
3.7 「Tap By Element」를 Flutter 세션에서도 사용할 수 있게 하기
여기서부터가 이번 작업의 핵심이다. appium-inspector를 포크(Fork) 동기화했더니, 요소의 하이라이터를 직접 클릭하면 그 자리에서 탭되는 「Tap By Element」 모드가 본가에 추가되어 있었다. 요소가 겹쳐 있을 경우 +/- 확장 센터에서 모호성을 해소하는 UI까지 표준으로 포함되어 있다. 직접 만든 모호성 해소 메뉴와 역할이 겹치는 부분도 있지만, 모처럼 추가된 표준 기능이므로 활용하지 않을 이유가 없다.
동기화 직후에 가벼운 사고도 있었다. 머지(Merge)로 인해 이전에 삭제했을 사용하지 않는 함수가 부활하면서, 정의되지 않은 상수를 참조하는 ESLint 에러가 재발했다. 실제로는 호출되지 않는 데드 코드(Dead code)였기에 실질적인 해는 없었지만, CI를 더럽히므로 삭제하여 수정했다.
본론인 Tap By Element에 대해 실제로 테스트해 보니 아무 일도 일어나지 않았다. 조사해 보니 Tap By Element는 내부적으로 표준 WebDriver의 elementClick(프로토콜상의 내부 커맨드 이름은 click)을 호출할 뿐, appium-flutter-driver 측에는 이 커맨드에 대한 핸들링이 전혀 없었다. FlutterDriver 클래스는 BaseDriver를 상속받고 있을 뿐, click을 오버라이드(Override)하지 않았다. 즉, Flutter 세션에서는 처음부터 작동하지 않는 상태였다.
대응책으로, appium-flutter-driver의 executeCommand에 Flutter 컨텍스트에서 click 커맨드를 받았을 때의 분기를 추가했다. 기존의 performActions용 분기와 동일한 패턴으로, 전달받은 elementId를 appium-handler 측의 'tap' 액션(3.4에서 추가한 것)으로 변환하여 전달하는 단순한 구현이다.
} else if (this.currentContext === FLUTTER_CONTEXT_NAME && cmd === `click`) { const elementId = args[0]; return await this.performActions([{actions: [{type: `tap`, elementId}]}]);}
appium-handler 측도, 지금까지는 「클릭 좌표 + 임의의 elementId」라는 전제였던 _resolveNode를 좌표 없이 elementId만으로도 해결할 수 있도록 nullable 대응을 완료했다. 좌표가 없는 경우에는 발견된 Widget 자체의 bounds에서 중심 좌표를 역산하여 탭(tap)을 실행한다.
이로써 Flutter 세션에서도 하이라이터를 직접 클릭하는 것만으로 탭할 수 있게 되었다.
3.8 의도적으로 대응하지 않은 것
Tap By Element를 동작시킨 후, Recorder 패널(조작을 테스트 코드로 내보내는 기능)에 「Code generation for action 'findAndAssign' is not currently supported」라는 코멘트가 나오게 되었다. 이를 수정할지 말지 조금 고민했으나, 결국 수정하지 않기로 했다.
이유는 Tap By Element가 요소 해결(element resolution)에 사용하는 로케이터(locator)를 추적해 보면, Flutter 세션에서는 실질적으로 xpath 전략밖에 후보로 남지 않으며, 게다가 그 xpath는 appium-handler가 getPageSource를 호출할 때마다 새로 부여하는 휘발성 합성 ID(inspector-N)를 포함하고 있기 때문이다. 이를 그대로 Dart 코드로 내보내더라도, 다음에 Page Source를 다시 가져오는 순간 존재하지 않는 ID를 참조하게 되어 동작하지 않는 코드밖에 되지 않는다.
자작 「탭하기」 메뉴(3.4)는 appium-handler 측에서 tooltip이나 semanticLabel, key, text와 같은 실제 Flutter finder 정보를 해결한 뒤에 기록한다. 테스트 코드로 재사용하고 싶다면 현재로서는 이쪽을 사용하는 것이 정답이라는 결론이다. Tap By Element는 탐색적인 조작용으로, 기록하여 재사용하고 싶을 때는 자작 메뉴를 사용하는 것으로 역할을 구분했다.
4. 요약
지난 기사는 「동작시키는 것」이 목표였다면, 이번에는 「실운영에 견딜 수 있는 수준」까지 끌어올리는 작업이었다. 버그 원인의 대부분은 Flutter의 위젯 트리(widget tree)가 표준적인 네이티브 UI 트리와는 전제가 다르다는 점 하나로 집약된다. key의 문자열화, 휘발성 ID, 범용 타입의 Widget이 좌표로 떨어지는 문제 모두 근본적인 원인은 그곳에 있다.
Appium Inspector와 같이 잘 만들어진 도구에 올라탈 경우, 본래의 내부 구현(이번 사례에서는 antd의 Dropdown이나 rc-trigger의 동작)까지 깊게 파고들어 조사해야 하는 점은 솔직히 힘들었다. 하지만 그만큼 포크(fork) 동기화로 신기능이 내려왔을 때, 우리만의 토대(appium-handler, appium-flutter-driver에 대한 수정 사항)가 어느 정도 정리되어 있었던 덕분에 Tap By Element에 대한 대응은 생각보다 빠르게 끝날 수 있었다.
다음에 한다면, 자작 모호성 해소 메뉴와 Tap By Element의 중복 부분을 정리하여 어느 한쪽으로 통합하는 것이라고 생각하고 있다. 아직 손을 대지는 않았다.
5. 참고 문헌
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기