iOS vs Android testing tips
Cross-platform Maestro testing — what's the same, what's different, and how to keep one flow working on both iOS and Android.
Maestro's selling point is that the same YAML drives both iOS and Android. In practice, "same YAML" is true for ~80% of selectors and ~60% of gestures. The remaining edge cases are where teams burn hours. This guide is the cheat sheet.
TL;DR
Prefer accessibility IDs over text selectors. Use runFlow with platform-specific subflows for genuinely divergent screens. Don't fight the platform — if iOS uses a tab bar and Android uses a navigation drawer, write two short flows, not one branching one.
What's identical
appId,tags,envconfig block.launchApp,tapOn,inputText,assertVisible,assertNotVisible.- Selector by
id(matches accessibility identifier on iOS, view-id-resource-name on Android). - Selector by
text. - Scrolling:
scroll,swipe,scrollUntilVisible.
For a well-instrumented app, 80% of flows look identical on both platforms.
What differs
System dialogs
- iOS: permission prompts ("Allow notifications?", "Allow location?") render in a system overlay outside the app's hierarchy. Use
tapOn: "Allow"— Maestro looks at the foreground window. - Android: permission dialogs are also system-level but the button text varies by API level ("Allow", "While using the app", "Only this time").
A safe pattern:
Keyboard
- iOS: the
returnkey has no consistent text label. UsepressKey: Enter. - Android: soft keyboard varies by manufacturer's keyboard app on physical devices. Stick to the AOSP keyboard (Gboard) on emulators.
Back button
- iOS: no system back button. Use
tapOnon the screen's chevron orswipe: { direction: RIGHT, start: "0%, 50%" }. - Android:
pressKey: Backworks system-wide.
A platform-conditional helper:
Web views
Both platforms expose the WebView hierarchy, but iOS's WKWebView reports element bounds in points; Android's WebView in pixels. tapOn by selector is fine on both. Coordinate-based taps are not portable.
App lifecycle
- iOS:
launchAppcold-starts every time by default. - Android:
launchAppmay resume an existing process. UseclearState: trueto force a cold start:
Authoring portable selectors
Rule of thumb, in order of preference:
- Accessibility ID (
id:) — most robust, survives translations. - Text (
text:) — fragile to copy changes and i18n. - Index within a parent — almost never use; a layout change breaks it silently.
Ask your iOS and Android engineers to set the same accessibilityIdentifier on iOS and android:contentDescription (or testTag in Compose) on Android. Even better: a shared config file that both platforms generate from.
Now the same flow works on both.
When to fork the flow
If a feature has genuinely different UX per platform — say, a tab bar on iOS and a nav drawer on Android — don't write a flow with five conditionals. Write checkout-ios.yaml and checkout-android.yaml, tag them appropriately, and run only the relevant one:
This is cleaner than maintaining a megaflow with platform branches.
Locale and time zone
Set both explicitly in your runner. Default device locale leaks into screenshots and breaks text: selectors when an engineer adds a new language to the bundle:
- iOS:
xcrun simctl spawn booted defaults write -g AppleLanguages "(en)" - Android:
adb shell am broadcast -a android.intent.action.LOCALE_CHANGED
Common pitfalls
- Different OS versions. A flow that works on iOS 17 may fail on iOS 16 because a system control was renamed. Pin OS in CI.
- Compose vs XML on Android. Compose UIs may not expose the same view IDs as XML. Use
testTagconsistently. - Notch and dynamic island. Coordinate-based taps near the top of the screen drift across device sizes. Avoid them.