mobile-testing
Use this skill when writing or configuring mobile app tests with Detox or Appium, setting up device farms (AWS Device Farm, Firebase Test Lab, BrowserStack), integrating crash reporting (Crashlytics, Sentry, Bugsnag), or distributing beta builds (TestFlight, Firebase App Distribution, App Center). Triggers on mobile e2e testing, native app automation, device matrix testing, crash symbolication, and OTA beta distribution workflows.
engineering mobiletestingdetoxappiumdevice-farmcrash-reportingWhat is mobile-testing?
Use this skill when writing or configuring mobile app tests with Detox or Appium, setting up device farms (AWS Device Farm, Firebase Test Lab, BrowserStack), integrating crash reporting (Crashlytics, Sentry, Bugsnag), or distributing beta builds (TestFlight, Firebase App Distribution, App Center). Triggers on mobile e2e testing, native app automation, device matrix testing, crash symbolication, and OTA beta distribution workflows.
mobile-testing
mobile-testing is a production-ready AI agent skill for claude-code, gemini-cli, openai-codex. Writing or configuring mobile app tests with Detox or Appium, setting up device farms (AWS Device Farm, Firebase Test Lab, BrowserStack), integrating crash reporting (Crashlytics, Sentry, Bugsnag), or distributing beta builds (TestFlight, Firebase App Distribution, App Center).
Quick Facts
| Field | Value |
|---|---|
| Category | engineering |
| Version | 0.1.0 |
| Platforms | claude-code, gemini-cli, openai-codex |
| License | MIT |
How to Install
- Make sure you have Node.js installed on your machine.
- Run the following command in your terminal:
npx skills add AbsolutelySkilled/AbsolutelySkilled --skill mobile-testing- The mobile-testing skill is now available in your AI coding agent (Claude Code, Gemini CLI, OpenAI Codex, etc.).
Overview
Mobile testing covers the end-to-end quality pipeline for native and hybrid mobile applications - from writing automated UI tests with Detox and Appium, to running them across real device farms, to capturing crashes in production and distributing beta builds for human verification. Unlike web testing, mobile testing must deal with platform fragmentation (iOS/Android), device-specific behavior, app lifecycle events, permissions dialogs, and binary distribution gatekeeping by Apple and Google.
Tags
mobile testing detox appium device-farm crash-reporting
Platforms
- claude-code
- gemini-cli
- openai-codex
Related Skills
Pair mobile-testing with these complementary skills:
Frequently Asked Questions
What is mobile-testing?
Use this skill when writing or configuring mobile app tests with Detox or Appium, setting up device farms (AWS Device Farm, Firebase Test Lab, BrowserStack), integrating crash reporting (Crashlytics, Sentry, Bugsnag), or distributing beta builds (TestFlight, Firebase App Distribution, App Center). Triggers on mobile e2e testing, native app automation, device matrix testing, crash symbolication, and OTA beta distribution workflows.
How do I install mobile-testing?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill mobile-testing in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support mobile-testing?
This skill works with claude-code, gemini-cli, openai-codex. Install it once and use it across any supported AI coding agent.
Maintainers
Generated from AbsolutelySkilled
SKILL.md
Mobile Testing
Mobile testing covers the end-to-end quality pipeline for native and hybrid mobile applications - from writing automated UI tests with Detox and Appium, to running them across real device farms, to capturing crashes in production and distributing beta builds for human verification. Unlike web testing, mobile testing must deal with platform fragmentation (iOS/Android), device-specific behavior, app lifecycle events, permissions dialogs, and binary distribution gatekeeping by Apple and Google.
When to use this skill
Trigger this skill when the user:
- Wants to write or debug a Detox e2e test for a React Native app
- Needs to set up Appium for native iOS or Android test automation
- Asks about running tests on AWS Device Farm, Firebase Test Lab, or BrowserStack
- Wants to configure crash reporting with Crashlytics, Sentry, or Bugsnag
- Needs to distribute beta builds via TestFlight, Firebase App Distribution, or App Center
- Asks about device matrix strategies or test sharding across real devices
- Wants to symbolicate crash reports or set up dSYM/ProGuard mapping uploads
- Is building a mobile CI/CD pipeline that includes automated testing and distribution
Do NOT trigger this skill for:
- Web browser testing with Cypress, Playwright, or Selenium (those are web-specific)
- React Native development questions unrelated to testing or distribution
Key principles
Test on real devices, not just simulators - Simulators miss touch latency, GPS drift, camera behavior, memory pressure, and thermal throttling. Use simulators for fast feedback during development, but gate releases on real-device test runs via device farms.
Separate test layers by speed - Unit tests (Jest/XCTest) run in milliseconds and cover logic. Integration tests verify module boundaries. E2e tests (Detox/Appium) are slow and flaky by nature - reserve them for critical user journeys only (login, purchase, onboarding). The pyramid still applies: many unit, fewer integration, fewest e2e.
Treat crash reporting as a first-class signal - Ship no build without crash reporting wired. Upload dSYMs and ProGuard mappings in CI, not manually. Monitor crash-free rate as a release gate - below 99.5% should block rollout.
Automate beta distribution in CI - Never distribute builds manually. Every merge to a release branch should trigger: build, test on device farm, upload to beta channel, notify testers. Manual uploads break traceability and invite version confusion.
Pin device matrices and OS versions - Define an explicit device/OS matrix in your CI config. Test against the minimum supported OS, the latest OS, and 1-2 popular mid-range devices. Do not test against "all devices" - it is slow, expensive, and the tail adds almost no signal.
Core concepts
Detox vs Appium - Detox is a gray-box testing framework built for React Native. It synchronizes with the app's JS thread and native UI, eliminating most timing-related flakiness. Appium is a black-box, cross-platform tool that uses the WebDriver protocol to drive native apps, hybrid apps, or mobile web. Use Detox for React Native projects (faster, less flaky). Use Appium when testing truly native apps (Swift/Kotlin) or when you need cross-platform parity from a single test suite.
Device farms - Cloud services that maintain pools of real physical devices. You upload your app binary and test suite, the farm runs tests across your chosen device matrix, and returns results with logs, screenshots, and video. AWS Device Farm, Firebase Test Lab, and BrowserStack App Automate are the major players. They differ in device availability, pricing model, and integration depth with their respective ecosystems.
Crash reporting pipeline - The SDK (Crashlytics, Sentry, Bugsnag) captures uncaught exceptions and native signals (SIGSEGV, SIGABRT) at runtime. Raw crash logs contain only memory addresses. Symbolication maps these addresses back to source file names and line numbers using debug symbols (dSYMs for iOS, ProGuard/R8 mapping files for Android). Without symbolication, crash reports are unreadable.
Beta distribution - Getting pre-release builds to internal testers and external beta users. Apple requires TestFlight for iOS (with mandatory App Store Connect processing). Android is more flexible - Firebase App Distribution, direct APK/AAB sharing, or Play Console internal tracks all work. Each channel has different compliance requirements, device limits, and approval latencies.
Common tasks
Write a Detox e2e test for React Native
Detox tests use element matchers, actions, and expectations. The test synchronizes automatically with animations and network calls.
// e2e/login.test.js
describe('Login flow', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
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.id('dashboard-screen'))).toBeVisible();
});
it('should show error on invalid credentials', async () => {
await element(by.id('email-input')).typeText('wrong@example.com');
await element(by.id('password-input')).typeText('bad');
await element(by.id('login-button')).tap();
await expect(element(by.text('Invalid credentials'))).toBeVisible();
});
});Always use
testIDprops in React Native components and match withby.id(). Never match by text for interactive elements - text changes with i18n.
Configure Appium for a native Android test
// wdio.conf.js (WebdriverIO + Appium)
exports.config = {
runner: 'local',
port: 4723,
path: '/wd/hub',
specs: ['./test/specs/**/*.js'],
capabilities: [{
platformName: 'Android',
'appium:deviceName': 'Pixel 6',
'appium:platformVersion': '13.0',
'appium:automationName': 'UiAutomator2',
'appium:app': './app/build/outputs/apk/debug/app-debug.apk',
'appium:noReset': false,
}],
framework: 'mocha',
mochaOpts: { timeout: 120000 },
};
// test/specs/login.spec.js
describe('Login', () => {
it('should authenticate successfully', async () => {
const emailField = await $('~email-input');
await emailField.setValue('user@example.com');
const passwordField = await $('~password-input');
await passwordField.setValue('password123');
const loginBtn = await $('~login-button');
await loginBtn.click();
const dashboard = await $('~dashboard-screen');
await expect(dashboard).toBeDisplayed();
});
});Run tests on AWS Device Farm
# buildspec.yml for AWS Device Farm via CodeBuild
version: 0.2
phases:
build:
commands:
- npm run build:android
- |
aws devicefarm schedule-run \
--project-arn "arn:aws:devicefarm:us-west-2:123456789:project/abc" \
--app-arn "$(aws devicefarm create-upload \
--project-arn $PROJECT_ARN \
--name app.apk \
--type ANDROID_APP \
--query 'upload.arn' --output text)" \
--device-pool-arn "$DEVICE_POOL_ARN" \
--test type=APPIUM_NODE,testPackageArn="$TEST_PACKAGE_ARN"Run tests on Firebase Test Lab
# Upload and run instrumented tests on Firebase Test Lab
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel6,version=33,locale=en,orientation=portrait \
--device model=Pixel4a,version=30,locale=en,orientation=portrait \
--timeout 10m \
--results-bucket gs://my-test-results \
--results-dir "run-$(date +%s)"Configure Crashlytics with dSYM upload in CI
# iOS - upload dSYMs after archive build
# In Xcode build phase or CI script:
"${PODS_ROOT}/FirebaseCrashlytics/upload-symbols" \
-gsp "${PROJECT_DIR}/GoogleService-Info.plist" \
-p ios \
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}"
# Android - ensure mapping file upload in build.gradle
# android/app/build.gradle
android {
buildTypes {
release {
minifyEnabled true
firebaseCrashlytics {
mappingFileUploadEnabled true
}
}
}
}Distribute via Firebase App Distribution in CI
# Install Firebase CLI and distribute
npm install -g firebase-tools
# Android
firebase appdistribution:distribute app-release.apk \
--app "1:123456789:android:abc123" \
--groups "internal-testers,qa-team" \
--release-notes "Build $(git rev-parse --short HEAD): $(git log -1 --format='%s')"
# iOS
firebase appdistribution:distribute App.ipa \
--app "1:123456789:ios:def456" \
--groups "internal-testers" \
--release-notes "Build $(git rev-parse --short HEAD)"Upload to TestFlight via Fastlane
# fastlane/Fastfile
platform :ios do
lane :beta do
build_app(
scheme: "MyApp",
export_method: "app-store",
output_directory: "./build"
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
apple_id: "1234567890",
changelog: "Automated build from CI - #{last_git_commit[:message]}"
)
end
end
# Run: bundle exec fastlane ios betaSet up Sentry for React Native crash reporting
// App.tsx - initialize Sentry
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0',
tracesSampleRate: 0.2,
environment: __DEV__ ? 'development' : 'production',
enableAutoSessionTracking: true,
attachStacktrace: true,
});
// Wrap root component
export default Sentry.wrap(App);# Upload source maps in CI
npx sentry-cli react-native xcode \
--source-map ./ios/build/sourcemaps/main.jsbundle.map \
--bundle ./ios/build/main.jsbundle
npx sentry-cli upload-dif ./ios/build/MyApp.app.dSYMAnti-patterns
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Testing only on simulators | Misses real-device issues: memory, thermal throttling, GPS, camera, touch latency | Use simulators for dev speed, gate releases on device farm runs |
| Writing e2e tests for every screen | E2e tests are slow and flaky - a full suite takes 30+ min and breaks CI | Reserve e2e for 5-10 critical journeys; cover the rest with unit/integration |
| Skipping dSYM/ProGuard upload | Crash reports show raw memory addresses instead of file:line - unreadable | Automate symbol upload in CI as a mandatory post-build step |
| Manual beta distribution | Builds lose traceability, testers get wrong versions, QA is blocked | Automate distribution in CI triggered by branch/tag rules |
| Hardcoding device sleep/waits | sleep(5) is unreliable across device speeds and farm latency |
Use Detox synchronization or Appium explicit waits with conditions |
| Testing against every OS version | Exponential matrix growth, diminishing returns past 3-4 versions | Pin min supported, latest, and 1-2 popular mid-range targets |
Gotchas
Detox tests become flaky when
testIDprops are missing on native components - Detox can only reliably target elements withtestIDset. Matching by text (by.text()) breaks as soon as a copy change or i18n update ships. Matching by type (by.type()) is fragile with component library upgrades. AddtestIDto every interactive element during development, not as a retroactive fix before testing.TestFlight processing delay blocks release timelines - Apple processes uploaded builds for TestFlight before they are available to testers. This takes 15 minutes to 2+ hours. Teams that schedule beta distributions the day before a release window get caught waiting. Buffer at least 4 hours for TestFlight processing in your release plan, or upload the previous night.
dSYM upload failures are silent until a crash occurs - If the Crashlytics or Sentry dSYM upload step fails in CI (auth error, network timeout), the build succeeds but crash reports arrive unsymbolicated. You only discover this when the first crash report is unreadable. Add a post-build check that validates the dSYM was uploaded successfully, not just that the upload script exited 0.
Device farm tests run in a clean app state, which differs from upgrade paths - Device farms install the app fresh for every test run. They never test the upgrade path from a prior version, which is how 90%+ of your real users will encounter a new release. Run upgrade-path tests separately by pre-installing the current App Store version, then installing the new build over it, before running your test suite.
Appium session timeouts differ across farm providers - AWS Device Farm, Firebase Test Lab, and BrowserStack all have different default session timeout values (5-20 minutes). A test suite that runs fine locally or on one platform will time out silently on another. Set explicit
newCommandTimeoutcapability values in your desired capabilities rather than relying on provider defaults.
References
For detailed content on specific topics, read the relevant file from references/:
references/detox-guide.md- Detox setup, configuration, matchers, actions, and CI integrationreferences/appium-guide.md- Appium server setup, desired capabilities, cross-platform patternsreferences/device-farms.md- AWS Device Farm, Firebase Test Lab, BrowserStack comparison and setup
Only load a references file when the current task requires deep detail on that topic.
References
appium-guide.md
Appium Guide
Appium is a cross-platform mobile test automation framework that uses the WebDriver protocol to drive native, hybrid, and mobile web applications on iOS and Android. It supports multiple client languages (JavaScript, Python, Java, Ruby, C#) and automation backends (XCUITest for iOS, UiAutomator2 for Android).
Installation
# Install Appium 2.x server
npm install -g appium
# Install platform drivers
appium driver install uiautomator2 # Android
appium driver install xcuitest # iOS
# Verify installation
appium driver list --installed
# Start the Appium server
appium --port 4723Required environment
- iOS: macOS with Xcode, Xcode Command Line Tools, and
ios-deploy(npm install -g ios-deploy) - Android: JDK 17+, Android SDK,
ANDROID_HOMEset, platform-tools in PATH - Both: Node.js 18+
Desired capabilities
Capabilities tell Appium which platform, device, and app to use.
Android (UiAutomator2)
{
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "Pixel 6",
"appium:platformVersion": "13.0",
"appium:app": "/path/to/app-debug.apk",
"appium:noReset": false,
"appium:autoGrantPermissions": true,
"appium:newCommandTimeout": 300
}iOS (XCUITest)
{
"platformName": "iOS",
"appium:automationName": "XCUITest",
"appium:deviceName": "iPhone 15",
"appium:platformVersion": "17.0",
"appium:app": "/path/to/MyApp.app",
"appium:noReset": false,
"appium:autoAcceptAlerts": true,
"appium:newCommandTimeout": 300
}Key capability flags
| Capability | Purpose | Default |
|---|---|---|
appium:noReset |
Do not clear app data before session | false |
appium:fullReset |
Uninstall app before and after session | false |
appium:autoGrantPermissions |
Auto-grant Android runtime permissions | false |
appium:autoAcceptAlerts |
Auto-accept iOS system dialogs | false |
appium:newCommandTimeout |
Seconds before server kills idle session | 60 |
appium:udid |
Target a specific physical device by UDID | auto-detect |
Locator strategies
| Strategy | Syntax (WebdriverIO) | Notes |
|---|---|---|
| Accessibility ID | $('~login-button') |
Best cross-platform strategy - maps to testID (RN), accessibilityIdentifier (iOS), content-desc (Android) |
| ID (resource-id) | $('android=new UiSelector().resourceId("com.app:id/btn")') |
Android only |
| XPath | $('//android.widget.Button[@text="Login"]') |
Slow and fragile - avoid |
| Class name | $('android.widget.EditText') |
Matches all elements of type |
| iOS predicate | $('-ios predicate string:name == "login"') |
iOS native predicate |
| iOS class chain | $('-ios class chain:**/XCUIElementTypeButton[name == "login"]') |
Faster than XPath on iOS |
Best practice: Always prefer accessibility ID (~) as the primary locator. It
works on both platforms and maps to semantic identifiers.
Common actions
// WebdriverIO client examples
// Find and interact
const emailField = await $('~email-input');
await emailField.setValue('user@example.com');
const loginBtn = await $('~login-button');
await loginBtn.click();
// Wait for element
const dashboard = await $('~dashboard-screen');
await dashboard.waitForDisplayed({ timeout: 10000 });
// Scroll
await $('android=new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text("Settings"))');
// iOS scroll
await driver.execute('mobile: scroll', { direction: 'down' });
// Swipe (gesture)
await driver.execute('mobile: swipeGesture', {
left: 100, top: 500, width: 200, height: 0,
direction: 'left', percent: 0.75,
});
// Take screenshot
await driver.saveScreenshot('./screenshots/result.png');
// Handle native alert (iOS)
await driver.acceptAlert();
// or
await driver.dismissAlert();
// Background and foreground
await driver.background(5); // send to background for 5 seconds
// Get element text
const text = await $('~welcome-text').getText();
// Check element state
const isDisplayed = await $('~login-button').isDisplayed();
const isEnabled = await $('~submit-button').isEnabled();Cross-platform test patterns
Shared test logic with platform-specific selectors
// selectors.js
const PLATFORM = driver.isAndroid ? 'android' : 'ios';
const selectors = {
loginButton: '~login-button', // accessibility ID works on both
backButton: PLATFORM === 'ios'
? $('-ios class chain:**/XCUIElementTypeButton[`name == "Back"`]')
: $('~navigate-back'),
};
module.exports = selectors;Page object pattern
// pages/LoginPage.js
class LoginPage {
get emailInput() { return $('~email-input'); }
get passwordInput() { return $('~password-input'); }
get loginButton() { return $('~login-button'); }
get errorMessage() { return $('~error-message'); }
async login(email, password) {
await this.emailInput.setValue(email);
await this.passwordInput.setValue(password);
await this.loginButton.click();
}
async getError() {
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
return this.errorMessage.getText();
}
}
module.exports = new LoginPage();WebdriverIO configuration
// wdio.conf.js
const path = require('path');
exports.config = {
runner: 'local',
port: 4723,
path: '/wd/hub',
specs: ['./test/specs/**/*.spec.js'],
maxInstances: 1,
capabilities: [{
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'emulator-5554',
'appium:app': path.resolve('./app/build/outputs/apk/debug/app-debug.apk'),
'appium:noReset': false,
}],
logLevel: 'info',
bail: 0,
waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
services: ['appium'],
framework: 'mocha',
reporters: ['spec'],
mochaOpts: {
ui: 'bdd',
timeout: 120000,
},
};Parallel execution with BrowserStack
// wdio.browserstack.conf.js
exports.config = {
...require('./wdio.conf').config,
user: process.env.BROWSERSTACK_USERNAME,
key: process.env.BROWSERSTACK_ACCESS_KEY,
services: ['browserstack'],
capabilities: [
{
'bstack:options': {
deviceName: 'Samsung Galaxy S23',
osVersion: '13.0',
projectName: 'MyApp',
buildName: `CI-${process.env.BUILD_NUMBER}`,
},
platformName: 'Android',
'appium:app': process.env.BROWSERSTACK_APP_URL,
'appium:automationName': 'UiAutomator2',
},
{
'bstack:options': {
deviceName: 'iPhone 15',
osVersion: '17',
projectName: 'MyApp',
buildName: `CI-${process.env.BUILD_NUMBER}`,
},
platformName: 'iOS',
'appium:app': process.env.BROWSERSTACK_APP_URL,
'appium:automationName': 'XCUITest',
},
],
};Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Session not created | Driver not installed or wrong capabilities | Run appium driver list; verify automationName matches installed driver |
| Element not found | Wrong locator or element not rendered yet | Use waitForDisplayed(); prefer accessibility ID over XPath |
| App crashes on launch | Incompatible APK/IPA with device OS version | Check minSdkVersion/deployment target matches device |
| Slow test execution | XPath locators or unnecessary waits | Replace XPath with accessibility ID; use explicit waits |
| Permission dialogs block test | System dialog not auto-handled | Set autoGrantPermissions (Android) or autoAcceptAlerts (iOS) |
detox-guide.md
Detox Guide
Detox is a gray-box end-to-end testing framework for React Native. It runs tests on real devices and simulators while synchronizing with the app's JS thread, native UI animations, and network activity to eliminate flakiness.
Installation and setup
# Install Detox CLI globally
npm install -g detox-cli
# Add Detox to your project
npm install --save-dev detox
# iOS: install applesimutils (required for simulator control)
brew tap wix/brew
brew install applesimutils
# Android: ensure ANDROID_HOME is set and an emulator is availableConfiguration file
// .detoxrc.js
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js',
},
jest: {
setupTimeout: 120000,
},
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
testBinaryPath: 'android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk',
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: { type: 'iPhone 15' },
},
emulator: {
type: 'android.emulator',
device: { avdName: 'Pixel_6_API_33' },
},
},
configurations: {
'ios.sim.debug': { device: 'simulator', app: 'ios.debug' },
'ios.sim.release': { device: 'simulator', app: 'ios.release' },
'android.emu.debug': { device: 'emulator', app: 'android.debug' },
'android.emu.release': { device: 'emulator', app: 'android.release' },
},
};Jest config for Detox
// e2e/jest.config.js
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
};Running tests
# Build the app first
detox build --configuration ios.sim.debug
# Run tests
detox test --configuration ios.sim.debug
# Run a specific test file
detox test --configuration ios.sim.debug e2e/login.test.js
# Run with retry on failure (useful in CI)
detox test --configuration ios.sim.release --retries 2
# Record video of test run (iOS only)
detox test --configuration ios.sim.debug --record-videos allElement matchers
| Matcher | Usage | Notes |
|---|---|---|
by.id(testID) |
element(by.id('submit-btn')) |
Primary matcher - use testID prop in RN |
by.text(text) |
element(by.text('Submit')) |
Matches visible text - fragile with i18n |
by.label(label) |
element(by.label('Close')) |
Matches accessibility label |
by.type(nativeType) |
element(by.type('RCTTextInput')) |
Native view type - platform-specific |
by.traits([traits]) |
element(by.traits(['button'])) |
iOS accessibility traits only |
Combining matchers
// Match element with both testID and text
element(by.id('greeting').and(by.text('Hello')));
// Match element inside a parent
element(by.id('item-title').withAncestor(by.id('item-row-3')));
// Match element containing a descendant
element(by.id('list-container').withDescendant(by.text('Item 5')));
// Match the 2nd element when multiple match
element(by.id('list-item')).atIndex(1);Actions
| Action | Usage | Notes |
|---|---|---|
tap() |
element(by.id('btn')).tap() |
Single tap |
longPress(duration) |
element(by.id('btn')).longPress(1500) |
Duration in ms |
multiTap(count) |
element(by.id('btn')).multiTap(2) |
Double-tap, triple-tap |
typeText(text) |
element(by.id('input')).typeText('hello') |
Types into focused field |
replaceText(text) |
element(by.id('input')).replaceText('new') |
Replaces without typing animation |
clearText() |
element(by.id('input')).clearText() |
Clears text field |
tapReturnKey() |
element(by.id('input')).tapReturnKey() |
Taps keyboard return |
scroll(offset, dir) |
element(by.id('list')).scroll(200, 'down') |
Scroll by pixels |
scrollTo(edge) |
element(by.id('list')).scrollTo('bottom') |
Scroll to edge |
swipe(dir, speed, pct) |
element(by.id('card')).swipe('left', 'fast', 0.75) |
Swipe gesture |
Expectations
await expect(element(by.id('title'))).toBeVisible();
await expect(element(by.id('title'))).not.toBeVisible();
await expect(element(by.id('title'))).toExist();
await expect(element(by.id('title'))).not.toExist();
await expect(element(by.id('title'))).toHaveText('Welcome');
await expect(element(by.id('title'))).toHaveLabel('Welcome header');
await expect(element(by.id('title'))).toHaveId('title');
await expect(element(by.id('switch'))).toHaveToggleValue(true);
await expect(element(by.id('slider'))).toHaveSliderPosition(0.5, 0.05);Device API
// Launch / reload
await device.launchApp({ newInstance: true });
await device.launchApp({ newInstance: true, permissions: { notifications: 'YES' } });
await device.reloadReactNative();
await device.terminateApp();
await device.installApp();
await device.uninstallApp();
// Device actions
await device.sendToHome();
await device.setBiometricEnrollment(true);
await device.matchFace(); // or matchFinger()
await device.unmatchFace();
await device.shake();
await device.setLocation(37.7749, -122.4194);
await device.setURLBlacklist(['.*google.com.*']);
await device.enableSynchronization();
await device.disableSynchronization();
await device.setStatusBar({ time: '12:34', batteryLevel: 100 });CI integration
GitHub Actions
name: Detox E2E
on: [push, pull_request]
jobs:
detox-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: brew tap wix/brew && brew install applesimutils
- run: detox build --configuration ios.sim.release
- run: detox test --configuration ios.sim.release --retries 2 --cleanup
- uses: actions/upload-artifact@v4
if: failure()
with:
name: detox-artifacts
path: artifacts/
detox-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Start Android emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
script: |
npm ci
detox build --configuration android.emu.release
detox test --configuration android.emu.release --retries 2Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Test hangs indefinitely | Detox waiting for animation/timer to finish | Disable looping animations; use device.disableSynchronization() around long timers |
| Element not found | Element off-screen or not yet rendered | Scroll to element first; use waitFor(element).toBeVisible().withTimeout(5000) |
| Build fails on CI | Missing Xcode/Android SDK | Pin Xcode version with xcode-select; use correct runs-on image |
| Flaky on Android emulator | Emulator not fully booted | Add boot wait in CI; use adb wait-for-device before running tests |
device-farms.md
Device Farms
Device farms provide pools of real physical devices in the cloud for running automated mobile tests at scale. This reference covers the three major services, their setup, pricing models, and how to integrate them into CI.
Comparison
| Feature | AWS Device Farm | Firebase Test Lab | BrowserStack App Automate |
|---|---|---|---|
| Device types | Real devices | Real devices + virtual | Real devices |
| Supported frameworks | Appium, XCUITest, Espresso, Calabash | Espresso, XCUITest, Robo, Game Loop | Appium, Espresso, XCUITest, Flutter |
| Pricing | Pay-per-minute or flat monthly | Free tier (10 tests/day), then pay-per-use | Per-parallel-test subscription |
| Video recording | Yes | Yes | Yes |
| Screenshot capture | Yes (per-step) | Yes | Yes |
| Network shaping | Yes | Limited | Yes |
| Private devices | Yes (dedicated fleet) | No | Yes (dedicated devices) |
| CI integrations | AWS CodePipeline, GitHub Actions | Firebase CLI, GitHub Actions | Native CI plugins, GitHub Actions |
| Best for | AWS-centric teams, private device needs | Android-first teams, Firebase ecosystem | Cross-platform teams, broadest device catalog |
AWS Device Farm
Setup
# Install AWS CLI
pip install awscli
# Configure credentials
aws configure
# Requires IAM permissions for devicefarm:* actionsUpload and run tests
# 1. Create a project (one-time)
PROJECT_ARN=$(aws devicefarm create-project \
--name "MyApp-E2E" \
--query 'project.arn' --output text)
# 2. Create a device pool (or use curated pools)
DEVICE_POOL_ARN=$(aws devicefarm create-device-pool \
--project-arn "$PROJECT_ARN" \
--name "Top Android Devices" \
--rules '[
{"attribute":"PLATFORM","operator":"EQUALS","value":"\"ANDROID\""},
{"attribute":"OS_VERSION","operator":"GREATER_THAN_OR_EQUALS","value":"\"12\""},
{"attribute":"MANUFACTURER","operator":"IN","value":"\"[\\\"Google\\\",\\\"Samsung\\\"]\""}
]' \
--query 'devicePool.arn' --output text)
# 3. Upload the app
APP_ARN=$(aws devicefarm create-upload \
--project-arn "$PROJECT_ARN" \
--name app-release.apk \
--type ANDROID_APP \
--query 'upload.arn' --output text)
# Wait for upload processing
aws devicefarm get-upload --arn "$APP_ARN" --query 'upload.status'
# 4. Upload test package
TEST_ARN=$(aws devicefarm create-upload \
--project-arn "$PROJECT_ARN" \
--name tests.zip \
--type APPIUM_NODE_TEST_PACKAGE \
--query 'upload.arn' --output text)
# 5. Schedule the run
RUN_ARN=$(aws devicefarm schedule-run \
--project-arn "$PROJECT_ARN" \
--app-arn "$APP_ARN" \
--device-pool-arn "$DEVICE_POOL_ARN" \
--test type=APPIUM_NODE,testPackageArn="$TEST_ARN" \
--execution-configuration jobTimeoutMinutes=30,videoCapture=true \
--query 'run.arn' --output text)
# 6. Poll for results
aws devicefarm get-run --arn "$RUN_ARN" --query 'run.{status:status,result:result}'GitHub Actions integration
- name: Run on AWS Device Farm
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Schedule Device Farm run
run: |
# Upload app and tests, then schedule run (see commands above)
# Store RUN_ARN and poll until completeFirebase Test Lab
Setup
# Install gcloud CLI
# https://cloud.google.com/sdk/docs/install
# Authenticate
gcloud auth login
gcloud config set project my-project-id
# Enable the Testing API
gcloud services enable testing.googleapis.comRun Android tests
# Instrumented tests (Espresso)
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel6,version=33,locale=en,orientation=portrait \
--device model=Pixel4a,version=30,locale=en,orientation=portrait \
--timeout 15m \
--results-bucket gs://my-test-results \
--results-dir "run-$(date +%Y%m%d-%H%M%S)" \
--num-flaky-test-attempts 2
# Robo test (automated exploration - no test code needed)
gcloud firebase test android run \
--type robo \
--app app-release.apk \
--device model=Pixel6,version=33 \
--timeout 5m \
--robo-directives "text:username_field=testuser,text:password_field=testpass,click:login_button="Run iOS tests
# XCUITest
gcloud firebase test ios run \
--test MyAppUITests.zip \
--device model=iphone14pro,version=16.6,locale=en_US,orientation=portrait \
--timeout 15m \
--results-bucket gs://my-test-resultsGitHub Actions integration
- uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Run Firebase Test Lab
run: |
gcloud firebase test android run \
--type instrumentation \
--app app-debug.apk \
--test app-debug-androidTest.apk \
--device model=Pixel6,version=33 \
--num-flaky-test-attempts 2BrowserStack App Automate
Setup
# Upload app binary
APP_URL=$(curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \
-X POST "https://api-cloud.browserstack.com/app-automate/upload" \
-F "file=@app-release.apk" \
-F "custom_id=MyApp-latest" \
| jq -r '.app_url')
echo "App uploaded: $APP_URL"WebdriverIO configuration
// wdio.browserstack.conf.js
exports.config = {
user: process.env.BROWSERSTACK_USERNAME,
key: process.env.BROWSERSTACK_ACCESS_KEY,
hostname: 'hub.browserstack.com',
services: ['browserstack'],
capabilities: [{
'bstack:options': {
projectName: 'MyApp',
buildName: `CI-${process.env.GITHUB_RUN_NUMBER || 'local'}`,
sessionName: 'Login Flow',
deviceName: 'Samsung Galaxy S23',
osVersion: '13.0',
networkLogs: true,
video: true,
debug: true,
},
platformName: 'Android',
'appium:app': process.env.BROWSERSTACK_APP_URL,
'appium:automationName': 'UiAutomator2',
}],
maxInstances: 5, // parallel sessions based on your plan
};REST API for results
# Get session details
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \
"https://api-cloud.browserstack.com/app-automate/sessions/$SESSION_ID.json"
# Get video URL
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \
"https://api-cloud.browserstack.com/app-automate/sessions/$SESSION_ID.json" \
| jq -r '.automation_session.video_url'
# Mark session as passed/failed
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \
-X PUT "https://api-cloud.browserstack.com/app-automate/sessions/$SESSION_ID.json" \
-H "Content-Type: application/json" \
-d '{"status":"passed","reason":"All assertions passed"}'Device matrix strategy
Recommended minimum matrix
| Slot | Purpose | Example |
|---|---|---|
| Min supported OS | Catch API-level incompatibilities | Android 10 / iOS 15 |
| Latest OS | Catch deprecation warnings, new permission models | Android 14 / iOS 17 |
| Popular mid-range | Real-world perf on constrained hardware | Samsung Galaxy A54 / iPhone SE 3 |
| Tablet (optional) | Catch layout issues on larger screens | iPad Air / Samsung Tab S9 |
Sharding strategy for CI
# Run different test suites on different devices to reduce total time
matrix:
include:
- device: "Pixel 6"
os: "13"
suite: "critical-path"
- device: "Samsung Galaxy S21"
os: "12"
suite: "regression"
- device: "Pixel 4a"
os: "11"
suite: "accessibility"Keep total device farm time under 20 minutes for PR checks. Run the full matrix on nightly or release branches only.
Cost optimization
- Use simulators/emulators for PR checks - Reserve real devices for nightly and release runs
- Pin specific devices - Avoid "any available device" which can cause unpredictable wait times
- Shard tests across devices - Run different test suites on different devices instead of all tests on all devices
- Use free tiers - Firebase Test Lab offers 10 free test executions per day on physical devices, 15 on virtual
- Set timeouts - Always set
--timeoutto prevent runaway sessions from burning budget - Cache builds - Upload the same binary once and reference it by ID across multiple test runs
Frequently Asked Questions
What is mobile-testing?
Use this skill when writing or configuring mobile app tests with Detox or Appium, setting up device farms (AWS Device Farm, Firebase Test Lab, BrowserStack), integrating crash reporting (Crashlytics, Sentry, Bugsnag), or distributing beta builds (TestFlight, Firebase App Distribution, App Center). Triggers on mobile e2e testing, native app automation, device matrix testing, crash symbolication, and OTA beta distribution workflows.
How do I install mobile-testing?
Run npx skills add AbsolutelySkilled/AbsolutelySkilled --skill mobile-testing in your terminal. The skill will be immediately available in your AI coding agent.
What AI agents support mobile-testing?
mobile-testing works with claude-code, gemini-cli, openai-codex. Install it once and use it across any supported AI coding agent.