| name | ui-testing-patterns |
| description | Compare and apply UI test tooling across mobile — Espresso, XCUITest, flutter_test / integration_test, Detox, and Maestro. Use when deciding which UI test layer fits a feature and how to structure selectors, waits, and page objects. |
UI Testing Patterns Across Mobile
Instructions
UI tests are the most expensive layer to run and to maintain. Pick the lightest tool that still covers the risk, write them in a page-object / screen-robot style, and keep selectors stable.
1. Pick the Right Tool
| Tool | Scope | Runs On | Best For |
|---|
| Espresso / Compose UI test | Android, in-process | Emulator / device | Single-screen behavior, fast iteration |
| Robolectric + Compose | Android, JVM | Host JVM | Compose component tests without an emulator |
| XCUITest | iOS, out-of-process | Simulator / device | End-to-end iOS flows |
| ViewInspector / SwiftUI Previews + snapshot | iOS, in-process | Simulator | SwiftUI component tests |
flutter_test (widget tests) | Flutter | Host Dart VM | Widget-level, fast |
integration_test | Flutter | Emulator / device | Real-device smoke |
| Detox | React Native | Emulator / device | Full RN E2E in JS |
| Maestro | Any mobile app | Emulator / device / real | Black-box YAML smoke flows, cross-platform |
Rule of thumb: prefer in-process component tests (Compose, SwiftUI views, flutter_test, RN Testing Library) for 80 % of UI coverage; reserve out-of-process (XCUITest, Detox, Maestro) for smoke and critical paths.
2. Selector Strategy
Tests break most often because someone changed a string or a layout. Use stable, semantic selectors:
- Android Views:
R.id.* resource IDs.
- Android Compose:
Modifier.testTag("checkoutPayButton") + onNodeWithTag.
- iOS UIKit:
accessibilityIdentifier.
- iOS SwiftUI:
.accessibilityIdentifier("checkoutPayButton").
- Flutter:
Key('checkoutPayButton') / ValueKey.
- React Native:
testID="checkoutPayButton" + getByTestId.
Never select by visible text unless the text is the thing under test (e.g., a localization check).
3. Page-Object / Screen-Robot Pattern
Wrap each screen in a test-only API so tests read like business steps, not framework calls.
Kotlin + Compose:
class CheckoutRobot(private val rule: ComposeTestRule) {
fun enterCard(number: String) = apply {
rule.onNodeWithTag("cardNumber").performTextInput(number)
}
fun tapPay() = apply { rule.onNodeWithTag("pay").performClick() }
fun assertSuccess() = apply { rule.onNodeWithTag("success").assertIsDisplayed() }
}
@Test fun happyPath() = composeRule.run {
CheckoutRobot(this).enterCard("4242...").tapPay().assertSuccess()
}
Swift + XCUITest:
struct CheckoutScreen {
let app: XCUIApplication
@discardableResult func enterCard(_ s: String) -> Self {
app.textFields["cardNumber"].tap()
app.textFields["cardNumber"].typeText(s); return self
}
@discardableResult func tapPay() -> Self { app.buttons["pay"].tap(); return self }
func assertSuccess() { XCTAssertTrue(app.staticTexts["success"].waitForExistence(timeout: 5)) }
}
Dart + flutter_test:
class CheckoutRobot {
CheckoutRobot(this.t);
final WidgetTester t;
Future<void> enterCard(String s) async {
await t.enterText(find.byKey(const Key('cardNumber')), s);
}
Future<void> tapPay() async => t.tap(find.byKey(const Key('pay')));
void assertSuccess() => expect(find.byKey(const Key('success')), findsOneWidget);
}
4. Waits — No sleep
Use the framework's own "wait until condition" primitive:
- Espresso —
IdlingResource plus sensible onView(...).check(matches(...)) which blocks on idling.
- Compose —
waitForIdle, waitUntil { composeRule.onAllNodesWithTag(...).fetchSemanticsNodes().isNotEmpty() }.
- XCUITest —
waitForExistence(timeout:), XCTNSPredicateExpectation.
- Flutter —
pumpAndSettle() (scoped timeout) or an explicit pump loop with a deadline.
- Detox —
waitFor(element(...)).toBeVisible().withTimeout(5000).
Never Thread.sleep / Task.sleep / await Future.delayed in UI tests. Waits must be condition-based.
5. Isolate from the World
Swap the network layer with a recorded or mocked server for the whole suite; swap the clock; seed a known user in local storage during @Before. UI tests must not depend on staging.
6. Cross-Platform Comparison
- Detox vs Maestro: Detox is JS-first and lives with the RN codebase; it covers complex flows with code. Maestro is YAML-first and works on any stack — prefer Maestro for smoke, Detox for deep RN-specific flows.
- Flutter
integration_test vs Maestro: integration_test gives Dart-level access and test semantics; Maestro is more robust on CI device farms and produces cleaner videos.
7. Failure Artifacts
Every UI test failure must produce: screenshot, view hierarchy / semantics dump, logs (logcat / os_log / Flutter debugDumpApp). Make that automatic in a @AfterEach / tearDown hook and upload from CI.
8. Checklist