Testing Mobile Applications: XCTest, Espresso, Detox and Appium
A test that fails on CI once in five runs without a reproducible cause is worse than its absence. Flaky tests are the main reason teams stop trusting test infrastructure and disable it.
Unit Tests: What to Test and What Not To
On iOS, XCTest is the foundation. Business logic in ViewModel, Interactor, UseCase — tests without problems if it doesn't pull UIKit. Typical mistake: logic in UIViewController directly — then unit test requires creating a view hierarchy, which is slow and unstable.
For asynchronous code in Swift: XCTestExpectation for old style, await + XCTest async for modern. With Combine — XCTestExpectation + sink, but more convenient to use libraries like CombineExpectations.
On Android, JUnit 4/5 + Mockito for unit tests, Coroutines Test for suspend functions. runTest {} from kotlinx-coroutines-test is the standard for testing ViewModel with StateFlow.
UI Tests: Stability Over Coverage
XCUITest (iOS) and Espresso (Android) are native UI tests. They run fast, are integrated with IDE, but test one platform.
The main problem with XCUITest is selector fragility. app.buttons["Login"] fails on localization change or refactoring accessibility label. Right approach: accessibilityIdentifier for testable elements, never text labels. Identifiers from shared enum — so they don't diverge between app and tests.
Espresso on Android is more stable due to IdlingResource mechanism — the test automatically waits for background operations to complete. But custom async operations (OkHttp, custom Executors) need to be registered in IdlingRegistry manually, otherwise the test won't synchronize with network requests.
Detox and Patrol: End-to-End for React Native and Flutter
Detox is an E2E framework for React Native developed by Wix. It runs on real devices and simulators via Gray Box approach: it knows about JS thread state and synchronizes with it. This solves the main flakiness source — the test doesn't click the button while the application is busy.
Setting up Detox is non-trivial. Requires special debug build with DetoxInstrumentsServer, configuration in package.json and no separate Appium server needed. Typical problem: test is stable on simulator, fails on real device due to animations. Solution — animations: disabled in Detox configuration for E2E build.
Patrol is the Flutter equivalent. Extends the built-in integration_test package and adds the ability to interact with native system dialogs (permission prompts, notifications) — something flutter_driver and basic integration_test can't do. For CI, use via patrol test --target integration_test/app_test.dart.
Appium: Cross-Platform with a Price
Appium is when you need to cover iOS and Android with the same tests. Uses WebDriver protocol over XCUITest and UiAutomator2 drivers. Speed is lower than native frameworks, but for teams without resources for two test code bases — a compromise.
Appium 2.x with plugin architecture is noticeably more convenient than the first version. appium-doctor diagnoses the environment — useful when setting up CI.
CI and Parallelization
For parallel XCUITest runs, use Xcode Cloud or xcodebuild test-without-building with multiple simulators via parallel-testing-enabled. Runtime for 200 UI tests with 4 simulator parallelization — from 40 minutes to 12.
| Framework | Platform | Gray Box | Speed | System Dialogs |
|---|---|---|---|---|
| XCUITest | iOS | No | High | Yes (via addUIInterruptionMonitor) |
| Espresso | Android | Yes (IdlingResource) | High | Limited |
| Detox | React Native | Yes | Medium | Limited |
| Patrol | Flutter | Partial | Medium | Yes |
| Appium | iOS + Android | No | Low | Yes |
Timeline: setting up test infrastructure from scratch (CI, unit + UI tests, reports) — 2-3 weeks. Writing coverage for an existing application — individually based on volume and state of the codebase.







