Mobile Testing
What it is
Section titled “What it is”Mobile testing verifies apps that run on devices you do not control: different OS versions, screen sizes, network conditions and hardware. It splits into two families that cover different kinds of behaviour.
unit / widget → fast, on-host: logic, view models, composablesUI / E2E → a real device or emulator exercising the full appUI tests further split by reach: a white-box test (Espresso, XCUITest) runs inside the app process and can see internals, while a black-box test (Appium, Maestro) drives the app the way a user does. Real-device clouds (Firebase Test Lab, BrowserStack App Automate) fill the gaps in your local device matrix.
When to use it / when to avoid it
Section titled “When to use it / when to avoid it”Use it for
- Critical journeys on each platform: onboarding, login, checkout, permissions
- Platform-specific behaviour: notifications, deep links, biometrics, rotation
- Verifying release builds on a representative device and OS matrix
- Flows that depend on device capabilities (camera, GPS, offline mode)
Avoid it when
- The behaviour is pure logic that a faster unit or widget test can cover
- You are testing the same journey exhaustively on every device — sample the matrix instead
- A shared cross-platform flow is already covered on one platform and the code is common
- You need web-only checks — that is cross-browser
Frameworks & tools
Section titled “Frameworks & tools”| Tool | Ecosystem | Notes |
|---|---|---|
| Appium | Multi (Android, iOS, web) | WebDriver protocol, cross-platform, black-box |
| Espresso | Android (Kotlin/Java) | Fast white-box UI tests in-process |
| XCUITest | iOS (Swift/Obj-C) | Apple’s native UI testing framework |
| Detox | React Native | Gray-box, auto-synchronises with the app |
| Maestro | Multi (Android, iOS, web) | YAML flows, low-flake, easy onboarding |
| Firebase Test Lab | Android, iOS | Real and virtual devices in the cloud |
| BrowserStack App Automate | Multi | Real device cloud for Appium/Espresso/XCUITest |
Quick setup
Section titled “Quick setup”npm install -g appiumappium driver install uiautomator2appium driver install xcuitestappiumnpm install -D detoxnpx detox build --configuration ios.sim.debugnpx detox test --configuration ios.sim.debugandroidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")androidTestImplementation("androidx.test.ext:junit:1.2.1")// Add a UI Testing Bundle target in Xcode,// then run with: xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'flutter test integration_test/app_test.dartpip install Appium-Python-Client<dependency> <groupId>io.appium</groupId> <artifactId>java-client</artifactId> <version>9.3.0</version> <scope>test</scope></dependency>dotnet add package Appium.WebDriverExample
Section titled “Example”it('logs in on Android', async () => { await $('~email').setValue('ada@example.com'); await $('~password').setValue('s3cret'); await $('~login').click(); await expect($('~dashboard')).toBeDisplayed();});def test_dang_nhap(driver): driver.find_element(AppiumBy.ACCESSIBILITY_ID, "email").send_keys("ada@example.com") driver.find_element(AppiumBy.ACCESSIBILITY_ID, "password").send_keys("s3cret") driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login").click() assert driver.find_element(AppiumBy.ACCESSIBILITY_ID, "dashboard").is_displayed()@Testfun loginReachesDashboard() { onView(withId(R.id.email)).perform(typeText("ada@example.com")) onView(withId(R.id.password)).perform(typeText("s3cret"), closeSoftKeyboard()) onView(withId(R.id.login)).perform(click()) onView(withId(R.id.dashboard)).check(matches(isDisplayed()))}func testLoginReachesDashboard() { let app = XCUIApplication() app.launch() app.textFields["email"].tap() app.textFields["email"].typeText("ada@example.com") app.secureTextFields["password"].tap() app.secureTextFields["password"].typeText("s3cret") app.buttons["login"].tap() XCTAssertTrue(app.otherElements["dashboard"].waitForExistence(timeout: 5))}appId: com.example.app---- launchApp- tapOn: "email"- inputText: "ada@example.com"- tapOn: "password"- inputText: "s3cret"- tapOn: "login"- assertVisible: "dashboard"func TestLogin(t *testing.T) { session := mustCreateSession(t) // POST /session defer deleteSession(t, session)
findByID(t, session, "email").SendKeys("ada@example.com") findByID(t, session, "password").SendKeys("s3cret") findByID(t, session, "login").Click() assertDisplayed(t, findByID(t, session, "dashboard"))}@Testvoid loginReachesDashboard() { driver.findElement(AppiumBy.accessibilityId("email")).sendKeys("ada@example.com"); driver.findElement(AppiumBy.accessibilityId("password")).sendKeys("s3cret"); driver.findElement(AppiumBy.accessibilityId("login")).click(); assertTrue(driver.findElement(AppiumBy.accessibilityId("dashboard")).isDisplayed());}[Fact]public void LoginReachesDashboard(){ _driver.FindElement(MobileBy.AccessibilityId("email")).SendKeys("ada@example.com"); _driver.FindElement(MobileBy.AccessibilityId("password")).SendKeys("s3cret"); _driver.FindElement(MobileBy.AccessibilityId("login")).Click(); Assert.True(_driver.FindElement(MobileBy.AccessibilityId("dashboard")).Displayed);}Anti-patterns & best practices
Section titled “Anti-patterns & best practices”- Prefer accessibility IDs. Coordinates and screen positions break on the next device.
- Wait for state, not the clock. Use framework synchronization and explicit waits over fixed sleeps.
- Sample the device matrix. Cover OS extremes, one low-end device and one large screen, not every model.
- Reset app state per test. Fresh install or deep clean avoids order-dependent flakiness.
- Run the slow device suite on a cloud and keep fast unit and widget tests local.
- Test the release build too. Debug-only behaviour and ProGuard/R8 stripping can break shipped apps.
Self-assessment quiz
Section titled “Self-assessment quiz”Self-assessment quiz
Pick one answer per question, then press Check answers.