native E2E (Maestro + MSW)
native(Expo) 앱의 E2E 는 Maestro(UI 플로우 러너) + MSW native(네트워크 모킹) 조합이다. web 은 Playwright + MSW + Vercel Preview 위에서 돈다(E2E, E2E mocks) — native 는 시뮬레이터에 설치된 dev-client 를 Maestro 가 조작하고, 앱 프로세스 안에서 뜬 MSW 서버가 네트워크를 가로챈다.
언제 작성하나: native 화면·기능을 추가할 때 그 화면의 깊은 플로우 E2E 를 여기에 함께 작성한다. 특히
게이팅/분기(인증 → 진입 목적지), 폼 제출, 알럿 분기처럼 여러 API 응답에 따라 UI 가 갈리는 흐름이 대상이다.
현재 커버리지: 본인인증(identity-verification, identity-verification-completed).
구성
apps/native-app/
├── .maestro/
│ ├── config.yaml # flows glob (flows/*.yaml)
│ ├── helpers/
│ │ ├── launch-e2e.yaml # 공통 런치(clearKeychain → stopApp → openLink → 온보딩 대기)
│ │ ├── onboard-to-phone-submit.yaml # 온보딩→약관→정보입력→인증요청 (env.NAME)
│ │ ├── enter-otp.yaml # OTP 6자리 칸별 입력 (env.D0..D5)
│ │ └── clear-otp.yaml # OTP 칸 비우기(정정 입력 전)
│ └── flows/
│ ├── identity-verification.yaml # 신규 유저 → listing-create (중복/정보오류/OTP오류 알럿 포함)
│ └── identity-verification-completed.yaml # 완료 유저 → 홈 분기
└── src/mocks/
├── handlers.ts # MSW 핸들러(본인인증 시나리오) — env.API_URL 기준
├── server.native.ts # setupServer(msw/native) + startMockServer()
├── polyfills.ts # Hermes 에 없는 web 전역 폴리필 (msw import 전에 평가)
└── e2eSetup.ts # 영속 캐시 비우기 + MSW 시작 (index.js 에서 E2E 일 때 호출)동작 핵심 (이 스택에서 통하는 방식)
-
MSW 부팅 타이밍:
index.js(엔트리)에서EXPO_PUBLIC_E2E === 'true'일 때만setupE2E()를 앱 렌더 전에 호출한다 → ① 영속 react-query 캐시(MMKVhowmuchhome.query-cache)를 비워 매 실행 fresh 게이팅으로 시작 ②msw/native서버 start. 일반 빌드는src/mocks/*를 require 조차 하지 않으므로 네트워크는 그대로 실서버로 나간다. 렌더 전에 start 해야 첫 게이팅 쿼리부터 mock 이 잡힌다. -
Hermes 폴리필 필수:
msw(내부@mswjs/interceptors)가Event/EventTarget/MessageEvent/BroadcastChannel/ReadableStream등 web 전역을 참조하는데 Hermes(RN 0.85)엔 없다.src/mocks/polyfills.ts가 이를 채우고,server.native.ts는 맨 첫 줄에서import './polyfills'로msw/nativeimport 이전에 평가되게 한다. 순서가 바뀌면[runtime not ready] ReferenceError: Property 'MessageEvent' doesn't exist로 부팅 크래시. (BroadcastChannel은 단일 프로세스라 no-op stub,structuredClone은 JSON round-trip 으로 폴리필) -
selector 는 전부 testID(
id:).react-native-css의Text/Button은 보이는 텍스트가 iOS 접근성 트리에 안 잡혀 Maestro 텍스트 매칭(tapOn: '시작하기')이 실패한다(화면엔 보이는데 못 찾음). 상호작용·단언 대상엔 반드시testID를 붙이고id:로만 매칭한다.@howmuchhome-web/ui/native의Button·TextField·OTPInput·Alert(확인 버튼 id =${testId}-btn-ok)가 testID 를 forward 한다. -
OTP 입력은 칸별 단일 입력(
enter-otp.yaml). OTP 는 칸마다 별도 입력이라, 한 칸에 전체 코드를 빠르게 넣으면 칸 자동전환과 경쟁해 글자가 누락된다(예:123457→1 3 5). 칸(iv-otp-0..iv-otp-5)마다tapOn+ 한 자리inputText. 6자리가 채워지면 폼이 자동 제출한다. 정정 시엔clear-otp.yaml(칸별eraseText: 1)로 비운 뒤 다시 입력. -
런치는
stopApp+openLink(launch-e2e.yaml).launchApp대신 앱을 먼저 종료한 뒤 deep link 로 콜드 런치해 dev-client 를 Metro(8081) 에 강제 연결한다.launchApp+openLink조합은 dev-client 가 런처 startup 과 경쟁하며 deep link 를 놓쳐 불안정했다.clearKeychain(SecureStore 인증 토큰)으로 로그아웃 상태에서 시작. MaestroclearState는 dev-client 런처/첫 실행 안내까지 리셋해 플로우를 방해하므로 쓰지 않고, 앱 상태(쿼리 캐시)는setupE2E()가 비운다.
MSW 핸들러 작성 (src/mocks/handlers.ts)
web apps/app/e2e/mocks/handlers.ts 의 시나리오를 native fetcher URL 기준으로 옮긴 것이다. 주의점:
-
URL 조합: 메인 API 는
${env.API_URL}/api/${getAPIPath(...)}(mainUrl(path)헬퍼, packages/apifetch.ts와 동일). front API 는${env.FRONT_API_URL}/${path}. -
요청 body 는 snake_case 로 온다. native fetcher 도 body 를 snake_case 로 직렬화하므로, body 를 비교할 땐
objectKeySnakeToCamel(@howmuchhome-web/utils-ts)로 정규화한 뒤 camelCase 로 읽는다(getBody<T>헬퍼). 이는 web 의e2eRequestBodyDeepEqual과 동일 취지다(E2E mocks). 직접request.json()결과를 camelCase 로 접근하면 항상undefined라 시나리오 분기가 조용히 어긋난다. -
getAPIPath는 핸들러 배열 생성 시점(모듈 로드 시)에 평가된다.getAPIPath(method, args)내부에서method.__apiMetadata.pathFn(args)를 호출하는데, 경로에 파라미터가 있는 메서드(예:AuthAPI.verifyAccess,ListingAPI.searchEstate)를 args 없이 호출하면pathFn이 필수 프로퍼티에 접근하다 throw → 모듈 로드가 터져 부팅 크래시한다. 해결: 경로 파라미터를 고정값으로 넘겨 mock한다.// ✅ 경로 파라미터를 고정해 그 경로만 mock (handlers.ts 실제 패턴) http.post( mainUrl(getAPIPath(AuthAPI.verifyAccess, {authIdentityVerificationId: 'abc'})), async ({request}) => { /* ... */ }, ); // ❌ args 누락 → pathFn(undefined) throw → 모듈 로드 크래시 http.post(mainUrl(getAPIPath(AuthAPI.verifyAccess)), handler);그래서 시나리오는 앞선 응답에서 고정 id(
abc=신규,def=완료,no-estate-iv-id=검색)를 심고, 뒤 핸들러가 그 id 로 경로를 고정 매칭한다. 값이 여러 개면 각 값마다 핸들러를 하나씩 등록한다. -
accessToken 은
Authorization: Bearer <token>헤더에서 읽는다(native 는 SecureStore 토큰을 fetcher 가 Authorization 으로 싣는다). 게이팅은 토큰별로 목적지가 갈린다: 토큰 없음 →me=null→ 본인인증,token→ 신규가입 → listing-create,token-completed→ 홈. -
미매칭 요청은 실서버로 통과한다(
onUnhandledRequest: 'bypass',server.native.ts). sentry/datadog/ 이미지 등을 흘려보내 시나리오에만 집중하기 위함이나, mock 을 빠뜨린 화면 API 는 실서버로 나가 401 이 된다 (트러블슈팅의AUTHENTICATION_ERROR참고). web 의createMSWResolver(X-HREF 가드)는 native 에선 쓰지 않는다 — native MSW 는 E2E 빌드에서만 start 하므로 항상 mocking 한다.
실행
# 1) Maestro CLI 설치 (최초 1회). iOS 시뮬레이터는 idb 불필요(번들 XCTest 드라이버).
curl -fsSL "https://get.maestro.mobile.dev" | bash
# 2) E2E 모드 Metro (EXPO_PUBLIC_E2E 번들 = MSW ON) 를 띄워 둔다(별도 터미널, 8081)
cd apps/native-app
yarn e2e:metro # APP_ENV=local EXPO_PUBLIC_E2E=true expo start --dev-client
# 3) dev-client 앱이 시뮬레이터에 설치돼 있어야 한다(yarn ios:local 등으로 1회 설치).
# 시뮬레이터는 하나만 부팅(중복 부팅 시 Maestro 대상 모호).
# 4) 플로우 실행 (드라이버 첫 기동이 오래 걸려 타임아웃을 키워 둠)
yarn e2e:test # = MAESTRO_DRIVER_STARTUP_TIMEOUT=240000 maestro test .maestro/flows
# 단일: maestro test .maestro/flows/identity-verification.yamlappId는 dev/local 빌드 기준co.howmuchhome.HowmuchhomeApp.dev.- 플로우는
clearKeychain(인증 토큰) +setupE2E(쿼리 캐시)로 로그아웃 상태에서 시작 → 게이팅이me=null로 판단해 본인인증 온보딩으로 진입.
트러블슈팅
-
iOS driver not ready in time: XCTest 드라이버 기동 지연.e2e:test가MAESTRO_DRIVER_STARTUP_TIMEOUT=240000을 설정한다. 그래도면 시뮬레이터 1개만 부팅했는지 확인.(idb 불필요) -
런치(
iv-onboarding-start안 보임)가 첫 시도에만 실패: 앱/공유 코드를 수정하면 그 다음 첫 run 은 Metro 콜드 리빌드가 런치 대기(180s)를 초과할 수 있다. 번들을 한 번 데우고(앱에서 deep link 로 한 번 로드) 재실행하면 통과. -
Render Error: AUTHENTICATION_ERROR/ 401: mock 안 된 API 가 실서버로 나가 인증 실패한 것. 해당 화면이 부르는 API 를handlers.ts에 추가한다(onUnhandledRequest: 'bypass'라 미매칭은 실서버로 감). -
부팅 즉시 크래시(모듈 로드 단계): 두 가지 흔한 원인 — ①
polyfillsimport 가msw/native뒤로 밀림 (ReferenceError: Property 'X' doesn't exist) ② 경로 파라미터 있는 메서드를getAPIPath에 args 없이 넘김 (Method does not have API metadata가 아니라 pathFn 내부 접근 throw). 위 “MSW 핸들러 작성” 참고. -
시나리오 분기가 조용히 어긋남(항상 default 로 감): body 를 camelCase 로 바로 읽어
undefined인 경우.getBody(objectKeySnakeToCamel)를 거쳐 비교한다.
검증 범위 메모
identity-verification(신규 유저): 온보딩 → 약관 → 정보입력 → 중복 인증 알럿 / 정보 오류 알럿 / OTP 오류 알럿 → 성공 → 집주인 인증(listing-create) 자동검색 화면(listing-search-progressing) 진입까지 전 분기 검증.identity-verification-completed(완료 유저): OTP 통과(iv-otp-0사라짐) + 신규유저 경로(listing-create)가 아님(assertNotVisible: listing-search-progressing)으로 게이팅 분기를 검증. 홈은 섹션마다 self-fetch 라 완전히 렌더하려면 홈 섹션 API 전체 mock 이 필요(= 홈 화면의 E2E 범위)하므로, 홈 UI 단언(tab-more등)은 홈 화면 추가 시 홈 mock 과 함께 붙인다.
새 native 플로우 E2E 추가하기
- 대상 화면의 상호작용·단언 요소에 testID 부여(텍스트로 못 잡힌다). 게이팅으로 분기하는 화면이면 진입 목적지가 달라지는 조건(native-app/navigation 의 진입 게이팅 참고)을 mock 으로 재현할 수 있는지 먼저 확인한다.
src/mocks/handlers.ts에 필요한 API 응답을 추가한다. webapps/app/e2e/mocks/handlers.ts의 동일 시나리오를 native fetcher URL(메인${env.API_URL}/api/${path}, front${env.FRONT_API_URL}/${path}) 기준으로 옮긴다. 경로 파라미터가 있는 메서드는getAPIPath(method, {param: 고정값})로 값을 고정해 등록한다(args 누락 시 부팅 크래시)..maestro/flows/<name>.yaml작성. 공통 진입/폼/OTP 는helpers/subflow 를runFlow로 재사용한다. 알럿 닫힘 뒤 다음 입력은extendedWaitUntil: {notVisible: ...}로 오버레이가 사라진 뒤 진행한다.yarn e2e:test로 실행. 첫 run 실패면 위 “런치 첫 시도 실패” 참고(번들 워밍업 후 재시도).- CI 연동은 아직(로컬 실행). self-hosted macOS 러너 또는 Maestro Cloud 는 후속.