Skip to content

Mobile Automation

Mobile automation is slower, flakier, and far more environment-dependent than web automation. Syntaxis automates fewer cases on mobile deliberately, picks the highest-value flows, and leaves the rest manual without apology.

Set up a harness first with /tests mobile — see Testing Setup.

Stack Default Why
Flutter integration_test Native to the toolchain, no extra service
React Native / Expo Maestro YAML flows, timing-tolerant, quick to set up
React Native with Detox already Detox Gray-box idle detection, fewer flakes
Ionic + Capacitor Playwright on the web build Most behaviour lives in the web layer
Native from shared code Appium The only genuine cross-platform native driver
Native iOS / Android with existing suites XCUITest / Espresso Fastest and least flaky per platform

Maestro is preferred over Appium for a new React Native harness: no server, no driver matrix, far less configuration for the same smoke coverage.

For Ionic, the web harness comes first. Appium is added only when the plan targets plugin behaviour, permissions, deep links, or store builds — and the web harness is never described as proving the native shell.

Use the platform’s accessibility identifier. It is stable, it is what assistive technology uses, and a missing one is a real accessibility defect worth reporting.

Platform Set with Query by
React Native testID testID or accessibility label
Flutter Key("..."), Semantics(identifier:) find.byKey, find.bySemanticsLabel
iOS native accessibilityIdentifier identifier
Android native resource-id, contentDescription resource id

Never select by screen coordinates, list index, or a translated string. Coordinates break on every screen size, and a translated string breaks the suite the moment localization lands.

.maestro/TC-021-expired-card.yaml
appId: com.example.app
---
- launchApp:
clearState: true
- tapOn: "Cart"
- tapOn: "Checkout"
- tapOn:
id: "card-4242"
- tapOn: "Place order"
# The expected result, both halves.
- assertVisible: ".*expired.*"
- assertVisible:
id: "cart-item"

One flow file per case, named with the case ID. clearState: true keeps flows independent. Retries are built into the commands, so there is no sleep to add.

it("TC-021 rejects an expired card", async () => {
await device.launchApp({ newInstance: true });
await element(by.id("cart-tab")).tap();
await element(by.id("checkout")).tap();
await element(by.id("card-4242")).tap();
await element(by.id("place-order")).tap();
await expect(element(by.text(/expired/))).toBeVisible();
await expect(element(by.id("cart-item"))).toBeVisible();
});

Detox needs a build of the app before the run; that build step is part of the documented command. Prefer by.id over by.text so the suite survives copy changes.

testWidgets('TC-021 rejects an expired card', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('checkout')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('card-4242')));
await tester.tap(find.byKey(const Key('place-order')));
await tester.pumpAndSettle();
expect(find.textContaining('expired'), findsOneWidget);
expect(find.byKey(const Key('cart-item')), findsOneWidget);
});

pumpAndSettle is the correct wait, not a sleep — but it hangs on an infinite animation such as a looping spinner, where a fixed number of pumped frames is right instead.

Widget tests are faster and cover most logic. integration_test is reserved for cases that genuinely need the whole app.

The cross-platform option, and the only one driving native iOS and Android from one suite. It needs an Appium server plus the platform driver (XCUITest, UiAutomator2) and is the slowest of the options, so the automated set stays small and high-value.

A mobile result means nothing without its environment. Every cycle records, and every report states:

  • simulator or emulator, versus a real device
  • which OS versions
  • whether a real payment, push notification, biometric, or camera path was exercised or stubbed

A green suite on one simulator does not prove the case on the target device matrix. Syntaxis says what was covered and what was not rather than implying the matrix passed.

Required, and it matters more here than on web: a flow that taps a missing element often reports a generic timeout that looks like a slow device. Invert the assertion, run the single flow, confirm it fails for the expected reason, then restore it.

Device logs, screenshots, recordings, .app and .apk builds, signing material, and stored credentials all stay out of Git.

Device suites are not added to Verify or CI without a separate request. CI runners usually have no simulator at all.