Skip to main content

UI Testing Mobile

Comprehensive strategies for testing mobile user interfaces on iOS and Android platforms.

Overview​

Mobile UI testing ensures your application works correctly across different devices, screen sizes, and operating systems. This includes functional testing, UI automation, performance testing, and device-specific testing.

Types of Mobile Testing​

1. Unit Testing​

2. Integration Testing​

3. UI Automation Testing​

4. Manual Testing​

5. Device Testing​

Mobile Testing Frameworks​

Detox (React Native)​

End-to-end testing framework for React Native.

Installation:

npm install --save-dev detox
npm install --save-dev detox-cli

Configuration:

// .detoxrc.json
{
"testRunner": "jest",
"runnerConfig": "e2e/config.json",
"configurations": {
"ios.sim.debug": {
"device": {
"type": "iPhone 14"
},
"app": "ios.debug"
},
"android.emu.debug": {
"device": {
"avdName": "Pixel_4_API_30"
},
"app": "android.debug"
}
},
"apps": {
"ios.debug": {
"type": "ios.app",
"binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/YourApp.app",
"build": "xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build"
},
"android.debug": {
"type": "android.apk",
"binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
"build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug"
}
}
}

Basic Test:

describe('Login Screen', () => {
beforeAll(async () => {
await device.launchApp();
});

beforeEach(async () => {
await device.reloadReactNative();
});

it('should show login screen', async () => {
await expect(element(by.id('login-screen'))).toBeVisible();
});

it('should login with valid credentials', async () => {
await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();

await expect(element(by.text('Welcome back'))).toBeVisible();
});

it('should show error with invalid credentials', async () => {
await element(by.id('email-input')).typeText('wrong@example.com');
await element(by.id('password-input')).typeText('wrongpass');
await element(by.id('login-button')).tap();

await expect(element(by.text('Invalid credentials'))).toBeVisible();
});
});

Gesture Testing:

describe('Gestures', () => {
it('should swipe to delete item', async () => {
await element(by.id('item-1')).swipe('left', 'fast', 0.8);
await expect(element(by.id('delete-button'))).toBeVisible();
await element(by.id('delete-button')).tap();

await expect(element(by.id('item-1'))).not.toBeVisible();
});

it('should scroll to bottom', async () => {
await element(by.id('list')).scrollTo('bottom');
await expect(element(by.text('End of list'))).toBeVisible();
});

it('should pinch to zoom', async () => {
await element(by.id('image')).pinchWithAngle('outward', 'slow', 0);
await expect(element(by.id('image'))).toHaveScale(2.0);
});
});

Appium​

Cross-platform mobile automation framework.

Installation:

npm install --save-dev appium
npm install --save-dev webdriverio

Configuration:

// wdio.conf.js
exports.config = {
runner: 'local',
port: 4723,
specs: ['./test/specs/**/*.js'],
capabilities: [
{
platformName: 'iOS',
'appium:deviceName': 'iPhone 14',
'appium:platformVersion': '16.0',
'appium:app': '/path/to/YourApp.app',
'appium:automationName': 'XCUITest',
},
{
platformName: 'Android',
'appium:deviceName': 'Pixel 4',
'appium:platformVersion': '11.0',
'appium:app': '/path/to/app-debug.apk',
'appium:automationName': 'UiAutomator2',
},
],
framework: 'mocha',
mochaOpts: {
timeout: 60000,
},
};

iOS Test:

describe('iOS App', () => {
it('should find element by accessibility id', async () => {
const button = await $('~login-button');
await button.click();
});

it('should type text', async () => {
const input = await $('~email-input');
await input.setValue('user@example.com');
expect(await input.getText()).toBe('user@example.com');
});

it('should navigate between screens', async () => {
await $('~products-tab').click();
await expect($('~product-list')).toBeDisplayed();
});
});

Android Test:

describe('Android App', () => {
it('should find element by resource id', async () => {
const button = await $('android=new UiSelector().resourceId("com.app:id/login")');
await button.click();
});

it('should scroll to element', async () => {
await $(
'android=new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(text("Terms"))',
);
await expect($('~terms-checkbox')).toBeDisplayed();
});

it('should handle native alerts', async () => {
await driver.acceptAlert();
});
});

Maestro​

Declarative mobile UI testing framework.

Installation:

curl -Ls "https://get.maestro.mobile.dev" | bash

Test Flow:

# login-flow.yaml
appId: com.example.app
---
- launchApp
- tapOn: 'Login'
- inputText: 'user@example.com'
- tapOn: 'Password'
- inputText: 'password123'
- tapOn: 'Sign In'
- assertVisible: 'Welcome back'

Running Tests:

maestro test login-flow.yaml

XCUITest (iOS Native)​

Swift Test:

import XCTest

class LoginTests: XCTestCase {
var app: XCUIApplication!

override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}

func testLoginSuccess() {
let emailField = app.textFields["email-input"]
emailField.tap()
emailField.typeText("user@example.com")

let passwordField = app.secureTextFields["password-input"]
passwordField.tap()
passwordField.typeText("password123")

app.buttons["login-button"].tap()

XCTAssertTrue(app.staticTexts["Welcome back"].exists)
}

func testLoginFailure() {
let emailField = app.textFields["email-input"]
emailField.tap()
emailField.typeText("wrong@example.com")

let passwordField = app.secureTextFields["password-input"]
passwordField.tap()
passwordField.typeText("wrongpass")

app.buttons["login-button"].tap()

XCTAssertTrue(app.staticTexts["Invalid credentials"].exists)
}
}

Espresso (Android Native)​

Kotlin Test:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)

@Test
fun testLoginSuccess() {
onView(withId(R.id.email_input))
.perform(typeText("user@example.com"), closeSoftKeyboard())

onView(withId(R.id.password_input))
.perform(typeText("password123"), closeSoftKeyboard())

onView(withId(R.id.login_button))
.perform(click())

onView(withText("Welcome back"))
.check(matches(isDisplayed()))
}

@Test
fun testLoginFailure() {
onView(withId(R.id.email_input))
.perform(typeText("wrong@example.com"), closeSoftKeyboard())

onView(withId(R.id.password_input))
.perform(typeText("wrongpass"), closeSoftKeyboard())

onView(withId(R.id.login_button))
.perform(click())

onView(withText("Invalid credentials"))
.check(matches(isDisplayed()))
}
}

Visual Testing​

Screenshotting​

Detox Screenshots:

describe('Visual Tests', () => {
it('should match homepage screenshot', async () => {
await device.takeScreenshot('homepage');
await expect(element(by.id('home-screen'))).toBeVisible();
});
});

Appium Screenshots:

it('captures screenshot on failure', async () => {
await driver.saveScreenshot('./screenshots/error.png');
});

Percy for Mobile​

import { percyScreenshot } from '@percy/appium-app';

describe('Visual regression', () => {
it('captures product screen', async () => {
await percyScreenshot(driver, 'Product Screen');
});
});

Device Testing​

Real Device Testing​

BrowserStack:

// wdio.conf.js
exports.config = {
user: process.env.BROWSERSTACK_USERNAME,
key: process.env.BROWSERSTACK_ACCESS_KEY,
hostname: 'hub-cloud.browserstack.com',
capabilities: [
{
'bstack:options': {
deviceName: 'iPhone 14',
osVersion: '16',
realMobile: true,
},
},
{
'bstack:options': {
deviceName: 'Samsung Galaxy S21',
osVersion: '11.0',
realMobile: true,
},
},
],
};

Sauce Labs:

capabilities: [
{
platformName: 'iOS',
'appium:deviceName': 'iPhone 14 Simulator',
'appium:platformVersion': '16.0',
'sauce:options': {
username: process.env.SAUCE_USERNAME,
accessKey: process.env.SAUCE_ACCESS_KEY,
},
},
];

Firebase Test Lab​

# Upload and run tests on Firebase
gcloud firebase test android run \
--type instrumentation \
--app app-debug.apk \
--test app-debug-test.apk \
--device model=Pixel4,version=30,locale=en,orientation=portrait

Performance Testing​

React Native Performance Monitor​

import { PerformanceObserver, performance } from 'react-native-performance';

const observer = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
console.log('Performance:', entry.name, entry.duration);
});
});

observer.observe({ entryTypes: ['measure'] });

// Measure screen render time
performance.mark('screen-start');
// ... render screen
performance.mark('screen-end');
performance.measure('screen-render', 'screen-start', 'screen-end');

Detox Performance Testing​

describe('Performance', () => {
it('measures screen transition time', async () => {
const start = Date.now();

await element(by.id('products-tab')).tap();
await waitFor(element(by.id('product-list')))
.toBeVisible()
.withTimeout(5000);

const duration = Date.now() - start;
expect(duration).toBeLessThan(1000); // Should load in under 1 second
});
});

Accessibility Testing​

iOS Accessibility Inspector​

func testAccessibility() {
let button = app.buttons["login-button"]

XCTAssertTrue(button.isAccessibilityElement)
XCTAssertEqual(button.accessibilityLabel, "Sign in")
XCTAssertEqual(button.accessibilityHint, "Tap to sign in to your account")
}

Android Accessibility Scanner​

@Test
fun testAccessibility() {
val button = onView(withId(R.id.login_button))

button.check(matches(isClickable()))
button.check(matches(hasContentDescription()))
}

Testing Best Practices​

1. Use Page Object Pattern​

// pages/LoginScreen.js
class LoginScreen {
get emailInput() {
return element(by.id('email-input'));
}

get passwordInput() {
return element(by.id('password-input'));
}

get loginButton() {
return element(by.id('login-button'));
}

get errorMessage() {
return element(by.id('error-message'));
}

async login(email, password) {
await this.emailInput.typeText(email);
await this.passwordInput.typeText(password);
await this.loginButton.tap();
}

async isVisible() {
await expect(this.emailInput).toBeVisible();
}
}

export default new LoginScreen();

// Usage in test
import LoginScreen from './pages/LoginScreen';

describe('Login', () => {
it('should login successfully', async () => {
await LoginScreen.isVisible();
await LoginScreen.login('user@example.com', 'password123');
await expect(element(by.text('Welcome'))).toBeVisible();
});
});

2. Handle Platform Differences​

const isIOS = device.getPlatform() === 'ios';
const isAndroid = device.getPlatform() === 'android';

describe('Platform-specific tests', () => {
it('handles back button', async () => {
if (isAndroid) {
await device.pressBack();
} else {
await element(by.id('back-button')).tap();
}
});
});

3. Wait for Elements​

// ✅ Good - Wait for element
await waitFor(element(by.id('loading-spinner')))
.not.toBeVisible()
.withTimeout(5000);

await waitFor(element(by.id('product-list')))
.toBeVisible()
.withTimeout(10000);

// ❌ Bad - Hardcoded delays
await new Promise(resolve => setTimeout(resolve, 3000));

4. Clean State Between Tests​

beforeEach(async () => {
await device.reloadReactNative();
// or
await device.launchApp({ newInstance: true });
});

afterEach(async () => {
await device.clearKeychain(); // iOS
await device.uninstallApp(); // Clean install for next test
});

5. Test on Multiple Devices​

const devices = [
{ name: 'iPhone 14', type: 'iPhone 14' },
{ name: 'iPhone SE', type: 'iPhone SE (3rd generation)' },
{ name: 'iPad Pro', type: 'iPad Pro (12.9-inch)' },
];

devices.forEach(({ name, type }) => {
describe(`Tests on ${name}`, () => {
beforeAll(async () => {
await device.selectDevice(type);
});

it('should display correctly', async () => {
// Test implementation
});
});
});

Debugging Mobile Tests​

Detox Debugging​

# Run with debug logs
detox test --loglevel trace

# Take screenshot on failure
detox test --take-screenshots failing

# Record video
detox test --record-videos failing

Appium Inspector​

# Launch Appium Inspector
appium-inspector

React Native Debugger​

// Enable debugging
import { NativeModules } from 'react-native';

if (__DEV__) {
NativeModules.DevSettings.setIsDebuggingRemotely(true);
}

Continuous Integration​

# .github/workflows/mobile-tests.yml
name: Mobile Tests

on: [push, pull_request]

jobs:
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2

- name: Install dependencies
run: |
npm install
cd ios && pod install

- name: Build app
run: detox build --configuration ios.sim.release

- name: Run tests
run: detox test --configuration ios.sim.release --cleanup

- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v2
with:
name: ios-screenshots
path: artifacts/

android:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2

- name: Setup Java
uses: actions/setup-java@v2
with:
distribution: 'zulu'
java-version: '11'

- name: Install dependencies
run: npm install

- name: Build app
run: detox build --configuration android.emu.release

- name: Run tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
script: detox test --configuration android.emu.release

- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v2
with:
name: android-artifacts
path: artifacts/

Test Organization​

e2e/
├── specs/
│ ├── login.spec.js
│ ├── products.spec.js
│ └── checkout.spec.js
├── pages/
│ ├── LoginScreen.js
│ ├── ProductsScreen.js
│ └── CheckoutScreen.js
├── helpers/
│ ├── gestures.js
│ ├── navigation.js
│ └── data.js
└── config/
├── detox.config.js
└── jest.config.js