Skip to Content
Native Appnative E2E (Maestro + MSW)

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 캐시(MMKV howmuchhome.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/native import 이전에 평가되게 한다. 순서가 바뀌면 [runtime not ready] ReferenceError: Property 'MessageEvent' doesn't exist 로 부팅 크래시. (BroadcastChannel 은 단일 프로세스라 no-op stub, structuredClone 은 JSON round-trip 으로 폴리필)

  • selector 는 전부 testID(id:). react-native-cssText/Button보이는 텍스트가 iOS 접근성 트리에 안 잡혀 Maestro 텍스트 매칭(tapOn: '시작하기')이 실패한다(화면엔 보이는데 못 찾음). 상호작용·단언 대상엔 반드시 testID 를 붙이고 id: 로만 매칭한다. @howmuchhome-web/ui/nativeButton·TextField· OTPInput·Alert(확인 버튼 id = ${testId}-btn-ok)가 testID 를 forward 한다.

  • OTP 입력은 칸별 단일 입력(enter-otp.yaml). OTP 는 칸마다 별도 입력이라, 한 칸에 전체 코드를 빠르게 넣으면 칸 자동전환과 경쟁해 글자가 누락된다(예: 1234571 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 인증 토큰)으로 로그아웃 상태에서 시작. Maestro clearState 는 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/api fetch.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.yaml
  • appId 는 dev/local 빌드 기준 co.howmuchhome.HowmuchhomeApp.dev.
  • 플로우는 clearKeychain(인증 토큰) + setupE2E(쿼리 캐시)로 로그아웃 상태에서 시작 → 게이팅이 me=null 로 판단해 본인인증 온보딩으로 진입.

트러블슈팅

  • iOS driver not ready in time: XCTest 드라이버 기동 지연. e2e:testMAESTRO_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' 라 미매칭은 실서버로 감).

  • 부팅 즉시 크래시(모듈 로드 단계): 두 가지 흔한 원인 — ① polyfills import 가 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 추가하기

  1. 대상 화면의 상호작용·단언 요소에 testID 부여(텍스트로 못 잡힌다). 게이팅으로 분기하는 화면이면 진입 목적지가 달라지는 조건(native-app/navigation 의 진입 게이팅 참고)을 mock 으로 재현할 수 있는지 먼저 확인한다.
  2. src/mocks/handlers.ts 에 필요한 API 응답을 추가한다. web apps/app/e2e/mocks/handlers.ts 의 동일 시나리오를 native fetcher URL(메인 ${env.API_URL}/api/${path}, front ${env.FRONT_API_URL}/${path}) 기준으로 옮긴다. 경로 파라미터가 있는 메서드는 getAPIPath(method, {param: 고정값}) 로 값을 고정해 등록한다(args 누락 시 부팅 크래시).
  3. .maestro/flows/<name>.yaml 작성. 공통 진입/폼/OTP 는 helpers/ subflow 를 runFlow 로 재사용한다. 알럿 닫힘 뒤 다음 입력은 extendedWaitUntil: {notVisible: ...} 로 오버레이가 사라진 뒤 진행한다.
  4. yarn e2e:test 로 실행. 첫 run 실패면 위 “런치 첫 시도 실패” 참고(번들 워밍업 후 재시도).
  5. CI 연동은 아직(로컬 실행). self-hosted macOS 러너 또는 Maestro Cloud 는 후속.
Last updated on