ProjectR Docs
Mobile

Testing

Jest setup, the time-zone reruns, and Maestro smoke and journey flows on a Release build.

Jest

jest-expo preset + Testing Library. Module aliases match the app. @/ maps to src, @convex/* to ../../packages/backend/convex, and @env to env.js. jest.config.js also maps bare react-native to the package, because jest-expo would otherwise turn tsconfig's types-only react-native path into a .d.ts mapper, and __mocks__/react-native-gesture-handler.ts replaces gesture handler, with a ReanimatedSwipeable mock beside it. jest-setup.ts stubs the HeroUI skeleton and mocks expo-secure-store, expo-router, expo-notifications and MMKV. The MMKV mock keeps an in-memory store per instance and clears it after each test, so a read returns what the code wrote.

Tests cover risky changes only, such as auth, checkout, cart, loyalty, and Convex integration behavior. The codebase holds to this with about 88 colocated test files.

Run: bun run test (add --runInBand locally). Known quirk: the suite can leave open handles after passing — use --forceExit only for verification reporting.

A date of birth is a calendar day, and its conversions break only where the local day and the UTC day differ. CI runs in UTC, where they never differ. bun run test:tz (scripts/test-tz.js) reruns the date suites under TZ=Asia/Kuala_Lumpur and TZ=America/Los_Angeles, and fails if Node does not know a zone. bun run test:ci, which test.yml runs, adds coverage to the full suite and then runs test:tz. Add a suite to SUITES in scripts/test-tz.js when its assertions depend on the zone.

Maestro (.maestro/)

  • The default run (bun run e2e-test, which is maestro test .maestro/ -e APP_ID=com.tipeer.projectr) runs smoke/guest-core, smoke/pdp and smoke/login in that order (flowsOrder in config.yaml). guest-core and pdp stay on the guest path. login signs in, so every default run spends one sendOtp token. pdp deep-links product 7499956715619, which has two variants, so the variant sheet opens.
  • Journeys are selected by folder. maestro test .maestro/journeys/ -e APP_ID=com.tipeer.projectr runs the 18 top-level flows (cart-checkout-handoff, loyalty-stamps-shake, duit-raya, onboarding-new-user, addresses-crud, …). config.yaml limits a run of .maestro/ to smoke/* and tags filter only inside that set, so --include-tags journey on .maestro/ selects nothing. Adding --include-tags journey to the folder command drops reviews-write.yaml, which has no tags. journeys/ux-polish/ runs only when named. Journeys are deliberately not default:
    1. sendOtp is rate-limited to 3/hour per phone number. A cleared install running everything exhausts the bucket, and every later flow then fails for a reason unrelated to the code. Journeys reuse warm sessions via utils/ensure-signed-in.yaml.
    2. Journeys mutate real dev-deployment data and are not order-independent.

Maestro on a Release build

Run flows against a Release build unless you are iterating on flow logic alone. A Release build has no dev-client launcher, scheme prompt, onboarding sheet or dev menu to get past, and utils/launch.yaml falls through to a plain launchApp. It embeds the JS bundle, so a new testID needs a rebuild of about 10 minutes. Batch the source changes, then rebuild once.

The SDK 58 dev client also runs flows on both platforms, through USE_DEV_CLIENT=true and DEV_CLIENT_URL. Its first visit to each route waits for Metro to bundle that route, so a wait that is fine on Release can time out there.

Build from apps/mobile with EXPO_NO_DOTENV=1 on both platforms:

EXPO_NO_DOTENV=1 npx expo run:ios --configuration Release --device <simulator-udid>
EXPO_NO_DOTENV=1 npx expo run:android --variant release

Without EXPO_NO_DOTENV=1, the Android Release bundling step loads .env.production and the APK embeds the production Convex URL, whatever the build log says. Local runs never touch production, so read the embedded URL before the first flow:

unzip -p android/app/build/outputs/apk/release/app-release.apk assets/app.config \
  | grep -o '"CONVEX_URL":"[^"]*"'

On iOS the same file is EXConstants.bundle/app.config inside the built .app. Run iOS flows on an iOS 26.5 simulator, where the SDK 58 Release build is proven to launch.

An SDK 58 Release build also reaches Home on the iOS 27.0 simulator, where SDK 57 builds trap at launch because they never adopted the UIScene lifecycle.

What launch.yaml handles

The smoke flows and all but one journey launch through utils/launch.yaml. journeys/deeplink-safety.yaml calls launchApp itself and expects a signed-in session, and any flow that launches that way has to handle the same interruptions on its own. With CLEAR_STATE=true it runs clearState and then clearKeychain, because the Better Auth session lives in the iOS keychain and clearState alone leaves a signed-in app. It taps through the iOS "Not Now" alert that clearKeychain raises, waits for the tab bar, and runs utils/dismiss-update-prompt.yaml.

A Release APK installed with adb counts as a sideload, so every cleared Android install opens the "New version available" update dialog. The screen under the dialog stays in Maestro's hierarchy, so a wait passes behind it and the next tap only closes it. dismiss-update-prompt.yaml taps the dialog away by the app-update-later testID, which works in every locale. The update gate is off in dev builds.

The notification pre-prompt appears only after an order confirmation, and no flow reaches that screen, so no flow has to dismiss it. launch.yaml still grants the notification permission, which keeps the pre-prompt away on Android and on devices. The grant does nothing on the iOS simulator.

Android selectors

Maestro matches id and text selectors as whole-string regexes, and Android exposes two elements differently from iOS:

  • A badge turns the bag tab's description into "My Bag, 6 new notifications". Select the tab as 'My Bag.*'.
  • A TextInput testID never reaches the Android hierarchy. Select the field by the accessibility label the app sets in code, such as 'OTP code input.*'. Android appends ", 0 of 6 digits entered" to it.

Treat a flow as unproven on a platform until it has passed there on a current Release build. Report such a check as INCONCLUSIVE, not as a pass.

The behavioral gate

For user-facing changes, drive the app (simulator or device) and capture what you saw. A green Jest run does not prove behavior.

Maestro's runner script is e2e-test (apps/mobile/package.json) — there is no test:maestro script.

On this page