
KMP로 앱을 개발하면서 제일 좋았던 건 역시 "코드 한 벌로 iOS/Android 둘 다"였다.
그런데 코드가 한 벌이면 뭐하나... QA는 두 벌이었다.
배포 전마다 하는 스모크 테스트가 대충 이랬다.
게다가 우리 서비스는 유저 타입이 여러 개로 나뉘어 있어서, 한 바퀴가 사실 세 바퀴다. 그걸 플랫폼별로 두 번씩. 기능 하나 고칠 때마다 이 짓을 반복하다 보니 어느 순간 현타가 왔다. 🤦🏻♂️
UI 코드는 Compose Multiplatform이라 100% 공유하는데, 왜 검증은 내 손가락이 여섯 바퀴를 돌고 있지?
그래서 도입한 게 Maestro다. 그리고 여기에 Claude(+ Figma MCP) 를 얹었더니 생각보다 훨씬 재밌는 물건이 됐다.
Maestro는 모바일 UI 테스트 프레임워크다.
테스트를 코드가 아니라 yaml 파일로 작성하는 게 특징이다. 실제로 쓰고 있는 스모크 테스트가 이렇게 생겼다.
appId: com.example.app.dev
---
# 스모크 테스트: 비로그인 상태에서 앱이 뜨고 로그인 화면이 보이는지 확인
- launchApp:
clearState: true
permissions:
all: allow
- tapOn:
text: "허용"
optional: true
- extendedWaitUntil:
visible: "이메일로 시작하기"
timeout: 20000
- takeScreenshot: smoke-login
이게 끝이다. 진짜 이게 끝이다.
기존 선택지들과 비교하면 이렇다.
| 플랫폼 | 작성 언어 | 러닝커브 | |
|---|---|---|---|
| Espresso | Android 전용 | Kotlin | 중 |
| XCUITest | iOS 전용 | Swift | 중 |
| Appium | 둘 다 | 아무거나 | 높음 (환경 세팅부터 고통) |
| Maestro | 둘 다 | yaml | 낮음 |
Espresso + XCUITest 조합은 같은 시나리오를 두 언어로 두 번 짜야 해서 탈락. Appium은 세팅 난이도로 탈락. 😇
Maestro는 CLI 하나 깔면 끝이고, 접근성 트리 기반이라 화면에 보이는 텍스트로 요소를 찾는다. 셀렉터 찾을 땐 maestro studio 열어서 눌러보면 된다.
Maestro는 화면의 텍스트/contentDescription으로 동작하는데, KMP의 특성을 생각해보자.
즉, yaml 플로우 파일 하나가 두 플랫폼에서 그대로 돈다.
# Android 실물폰에서
./.maestro/run_all.sh <adb serial>
# iOS 시뮬레이터에서 — 스크립트도, 플로우도 전부 그대로
./.maestro/run_all.sh <시뮬레이터 UDID>
기기 ID만 바꾸면 끝. 네이티브를 따로 개발하는 팀이라면 "iOS는 버튼 문구가 미묘하게 다른데..." 같은 분기가 생기기 마련인데, KMP는 같은 Composable이 그리는 화면이라 구조적으로 그럴 일이 없다.
물론 플랫폼 차이가 아예 없진 않다. 그런 부분만 Maestro의 when: platform 조건으로 처리한다. 실제 공용 로그인 플로우의 한 조각이다.
# hideKeyboard 는 iOS 에서 불안정 — Android 에서만 수행
- runFlow:
when:
platform: Android
commands:
- hideKeyboard
# iOS 는 알림 권한 다이얼로그가 런타임에 뜨므로 허용 탭
- runFlow:
when:
platform: iOS
commands:
- tapOn:
text: "허용"
optional: true
플로우 파일 60개 중에 플랫폼 분기는 이런 시스템 다이얼로그/키보드 정도가 전부다.
플로우가 늘어나면서 파일 구조를 잡았는데, 서비스의 유저 타입을 최상위로, 그 아래를 피쳐 단위로 나눴다. 대충 이런 모양이다.
.maestro/
├── run_all.sh # 읽기(탐색) 스위트 — 데이터 변경 없음
├── run_write.sh # 쓰기 시나리오 — 테스트 서버 데이터 변경됨!
├── smoke.yaml # 비로그인 스모크
├── common/ # 공용 서브플로우 (로그인, 탭 순회 등)
├── user_a/ # 유저 타입 A: 탐색/신청/작성/마이페이지 ...
├── user_b/ # 유저 타입 B: 검색/제안/검토/결제 직전 ...
└── user_c/ # 유저 타입 C: 콘텐츠 등록 (유형별로 파일 분리)
이렇게 나누니 좋은 점:
run_write.sh로 격리runFlow: common/login.yaml 한 줄로 모든 플로우에서 재사용깨알 팁 하나: 매 플로우마다 정식 로그인을 태우면 느리고 잘 깨진다. 그래서 디버그 빌드에만 숨은 디버그 로그인을 뚫어놨다 — 로그인 화면에서 로고 더블탭 → 숨은 입력창에 테스트 닉네임 입력 → 로고 롱프레스하면 테스트 API가 닉네임만으로 토큰을 발급해준다. 모든 플로우가 runFlow 한 줄로 이 공용 로그인을 타니까 빠르고 안정적이다. E2E 하려면 이런 테스트 전용 통로에 대한 투자가 은근 중요하다.
사실 이 글의 진짜 주제는 여기부터다. 플로우 60개를 내가 다 손으로 짰냐? 아니다. Claude Code한테 시켰다.
방식은 이렇다.
maestro hierarchy / 스크린샷 덤프를 Claude가 읽는다. 플로우가 실패하면 Maestro가 남기는 UI 계층 덤프와 스크린샷을 에이전트가 읽고 셀렉터를 스스로 고친다. "같은 라벨이 두 개라 index를 줘야 한다" 같은 것도 계층 덤프 보고 알아서 처리한다.그 결과물이 재밌는 게, 에이전트가 삽질하며 배운 내용이 .maestro/README.md에 계속 축적된다. 예를 들면 이런 것들이다.
- iOS에서
hideKeyboard는 불안정 → 키보드 해제는 "항상 보이는 상단 타이틀 탭"으로. 키보드가 덮은 좌표를 탭하면 자판이 눌려 필드에 글자가 들어가는 사고가 남 (실제 발생)- 상세 화면의 지도 뷰가 화면 중앙에 오면 기본 scroll을 지도가 삼켜 멈춤 → 스와이프 좌표를 지정한 공용 스크롤 플로우 사용
- iOS
eraseText는 빈 문자열 붙여넣기로 구현이라, Mac 클립보드가 시뮬레이터와 동기화돼 있으면 복사해둔 텍스트가 필드에 들어감 → 플로우 전에 클립보드 비우기
이 지식이 문서로 남으니, 다음에 플로우를 추가할 때 에이전트가 같은 삽질을 반복하지 않는다. 에이전트의 경험치가 레포에 쌓이는 느낌이라 꽤 신선했다.
E2E를 돌리는 주체가 "앱 소스를 전부 읽을 수 있는 에이전트"라는 게 어떤 의미냐면, 테스트 실패가 곧바로 버그 수정으로 이어진다는 거다. 실제로 이 조합으로 찾아서 고친 것들이다.
수동 QA였으면 유저 제보를 받고 나서야 디버깅을 시작했을 것들이다. 지금은 밤에 스위트 돌려놓으면 아침에 "이런 버그 찾아서 이렇게 고쳤다"는 리포트가 와 있는 흐름이 된다. 이게 되니까 QA 자동화가 아니라 거의 QA 엔지니어를 한 명 앉혀놓은 느낌이다.
솔직하게 아쉬운 점도 적어본다.
정리하면 이렇다.
KMP로 UI를 한 벌로 만들었다면, QA도 yaml 한 벌로 만들 수 있다.
그리고 그 yaml을 쓰고, 돌리고, 실패 원인을 고치는 것까지 에이전트에게 맡길 수 있다.
KMP 하는 팀이라면 Maestro는 진짜 도입 비용 대비 효율이 미쳤다. 거기에 코드 읽을 줄 아는 에이전트까지 붙이면 QA의 차원이 달라진다. 끝!