ModulesSetupLabQuizAll courses

Mobile Testing: iOS and Android

Run the same kind of test on a simulator, an emulator or a real phone: set up the device, point e2e at your app, and learn what changes when there is no URL bar.

Module 4 of 5 · AI-Powered E2E Testing

Module 04 ~40 min read + lab iOS & Android

What you will learn

Prerequisites: Module 2 (locators, goals, assertions) and the project from Module 1. For the lab you need either Xcode with an iOS simulator runtime (macOS only) or the Android SDK with an emulator (macOS, Linux, or Windows via WSL).

What we actually ran

The Android screenshots come from a real run on a Pixel 3a (API 34) emulator with e2e 0.18.0 and @e2e-dev/mobile 0.10.0. The Mac we used had no Xcode simulator, so the iOS steps in this module follow the official docs; the API is the same on both platforms.

1. How the mobile engine works

The web engine drives a browser through Playwright. The mobile engine, @e2e-dev/mobile, drives devices through agent-device, an open-source tool from Callstack that can read a device's accessibility tree, tap, type and swipe. Your test talks to e2e; e2e talks to the agent-device daemon; the daemon talks to the simulator, emulator or phone.

tests/*.e2e.ts app · screen · agent e2e runner model · cache · report @e2e-dev/mobile mobile({ platform }) agent-device daemon iOS simulator Android emul. Real phone
The same test API on every device. Only the engine and the target change. Figure: drawn for this course.

2. Set up a device and check it

PlatformYou needNotes
iOSmacOS, Xcode, and an iOS simulator runtime (Xcode → Settings → Components)Command Line Tools alone are not enough: there is no simctl without full Xcode.
AndroidAndroid SDK with an emulator image (Android Studio → Device Manager)Export ANDROID_HOME (on macOS usually ~/Library/Android/sdk) and put platform-tools on PATH so adb works.
BothNode.js 24.8+ (or 22.22.3+), e2e and @e2e-dev/mobilenpm install -D e2e @e2e-dev/mobile, or choose Mobile (iOS/Android) in npx e2e init.

Before writing a single test, ask agent-device what it can see:

export ANDROID_HOME=~/Library/Android/sdk export PATH="$PATH:$ANDROID_HOME/platform-tools" npx agent-device doctor
agent-device doctor output showing one booted Android device and warnings for Apple, HarmonyOS and Vega tooling
Our doctor run: 1 Android device available and booted. The Apple warning is real and expected on this Mac (only Command Line Tools, no Xcode), and the HarmonyOS and Vega lines are for platforms we do not use. "No hard blockers" means Android is ready. Screenshot: course run.

Read the hints

Every warning comes with a hint: line that tells you exactly what to install or export. Fix only the platform you plan to test; warnings for others are harmless.

3. Point e2e at your app

A mobile target replaces web() with mobile({ platform }) and replaces the URL with the app's identity. This is the exact config of our Android run. We tested the built-in Settings app, so every emulator already has it installed:

// e2e.config.ts import type { E2EConfig } from 'e2e'; import { mobile } from '@e2e-dev/mobile'; import { google } from '@ai-sdk/google'; export default { agents: { default: { model: google('gemini-3.8-flash') } }, targets: [{ name: 'android', engine: mobile({ platform: 'android' }), app: { bundleId: 'com.android.settings' } }], workers: 1, } satisfies E2EConfig;

The iOS version, as generated by the wizard, uses the bundle id Settings:

targets: [{ name: 'ios', engine: mobile({ platform: 'ios' }), app: { bundleId: 'Settings' } }], workers: 1,
KeyMeaning
app.bundleIdThe iOS bundle id or Android package that app.open(), app.restart() and app.clearState() launch.
app.appPathA build to install: an iOS simulator .app or an Android .apk. The engine installs nothing on its own; call device.installApp() (no argument installs appPath), for example in a fixture.
workers: 1One worker per device. Two workers never share a device.
{ name: 'ios', engine: mobile({ platform: 'ios' }), app: { bundleId: 'com.example.app', appPath: './build/MyApp.app', // the iOS .app bundle }, }

4. Worked example: two tests on Android

The first test is purely deterministic. The second hands navigation to the agent and lets the model judge the result. Note the imports: test comes from the engine package, expect from e2e.

// tests/settings.e2e.ts import { test } from '@e2e-dev/mobile'; import { expect } from 'e2e'; test('Settings opens', async ({ app, screen }) => { await app.open(); await expect(screen.getByText('Network & internet')).toBeVisible(); }); test('the agent finds the Android version', async ({ app, agent }) => { await app.open(); await agent.act('open About emulated device'); await agent.assert('the screen shows the Android version or device details'); });
Android Settings home screen listing Network and internet, Connected devices, Apps and more
What app.open() shows: the Settings home with "Network & internet", the text the first test checks. Screenshot: Pixel 3a emulator.
Android About emulated device screen showing device name, model and SIM status
The screen the agent reached. agent.assert saved this capture as evidence. Screenshot: course run artifact.
Terminal output of npx e2e run on Android with two passing tests in 49.85 seconds
The run: e2e found the booted emulator, prepared the engine in 11.21 s, and passed both tests. The agent needed 3 model calls to find "About emulated device" (it is at the bottom of the list) and 1 to judge it. Screenshot: course run.

Device runs are slower than browser runs: here about 50 seconds for two tests, against a few seconds on the web. That is one more reason to keep agent steps short and let the cache (Module 2) replay them.

5. The device fixture, launch arguments and permissions

On a device, screen maps native controls to common roles such as button, textbox, listitem and switch, and getByTestId reads the native id (the accessibility identifier on iOS, the resource id on Android). For everything else there is the device fixture: network, appearance, permissions, installs and deep links, all without a model call. This example from the docs runs on iOS:

import { test } from '@e2e-dev/mobile'; import { expect } from 'e2e'; test('shows the version offline in dark mode', async ({ agent, app, device, screen }) => { await device.setAppearance('dark'); await device.setNetwork('offline'); await app.open(); await agent.act('go to General, then About'); await expect(screen.getByRole('button', /^iOS Version/)).toBeVisible(); await expect(device.locator('role=NavigationBar id=About')).toBeVisible(); });

Launch arguments and permissions are set on the target's app and apply to every fresh launch, so no permission prompt interrupts the test:

app: { bundleId: 'com.example.app', launchArguments: ['-e2e', 'YES'], permissions: { camera: 'grant', notifications: 'deny', location: 'reset' }, },

One test can launch with its own settings, and deep links open a route directly:

await device.openApp('com.example.app', { relaunch: true, permissions: { camera: 'deny' } }); await device.openLink('myapp://orders/42'); await expect(screen.getByText('Order #42')).toBeVisible();
FrameworkSet the test id with
React Native / ExpotestID
SwiftUI.accessibilityIdentifier()
Jetpack Compose / Compose MultiplatformModifier.testTag(), plus testTagsAsResourceId = true on a root composable for Android
FlutterSemantics(identifier:), wrapped in MergeSemantics for buttons

6. Several devices, real phones, hosted devices

Both platforms in one suite

Add a target per platform; each test runs on both. Use platforms: ['ios'] or ['android'] on tests whose labels differ.

const app = { bundleId: 'com.example.app' }; export default { targets: [ { name: 'iphone', engine: mobile({ platform: 'ios' }), app }, { name: 'pixel', engine: mobile({ platform: 'android' }), app }, ], workers: 1, } satisfies E2EConfig;

To run files in parallel, give a device pool and set workers to at least its size: mobile({ platform: 'ios', device: ['iPhone 17', 'iPhone 17 Pro'] }). Without device, the engine uses booted devices up to the worker limit, and boots one if none is running (in our run it found emulator-5554 already booted).

Physical phones

agent-device drives a plugged-in phone the same way. List devices with npx agent-device devices --platform ios and name the phone: mobile({ platform: 'ios', device: 'QA iPhone' }). Use a unique name, not an id.

Hosted devices

A device can also be a device provider that leases hosted devices for the run. For EAS Simulators (limited access at the time of writing) install @e2e-dev/eas and pass device: easSimulators({ buildId: process.env.EAS_BUILD_ID }) to mobile(). Tests and CI stay the same, and the machine running e2e needs no Mac, Xcode or Android SDK.

7. What differs from the web

On the webOn a device
app.open('/path')No URL. app.open() launches bundleId fresh; device.openLink(url) opens a deep link.
Sign in once with test.setup and reuse the sessionNot available: sign in per test, or seed the app.
--video writes a WebM--video writes an MP4 of the device screen with taps shown.
Cache keyed on the page pathCache keyed on <bundle id> / <screen title>.
selectOption, setInputFiles, scrollIntoViewUnsupported; use taps and swipes.

Between tests the app stays where the last one left it, so start each test with app.open(). For CI, a mobile job does four things: build the app (a Release simulator .app or .apk), boot a device, install the build, and run npx e2e run. iOS needs a macOS runner; Android needs Linux with KVM. Module 5 shows a GitHub Actions workflow.

Example projects

The e2e repository has ready-to-run mobile examples for Expo, SwiftUI, Jetpack Compose, Kotlin Multiplatform and Flutter, each with the same three locator tests on a greeting screen.

Lab: test the Settings app on your device

About 20 minutes. Choose Android (any OS) or iOS (macOS with Xcode).

1

Start a device and check it

Start an emulator from Android Studio's Device Manager, or a simulator from Xcode. Then run npx agent-device doctor and fix any hint for your platform.

2

Create the project

mkdir taskflow-mobile && cd taskflow-mobile npm init -y npm install -D e2e @e2e-dev/mobile ai zod @ai-sdk/google # or your provider mkdir tests
3

Config and tests

Copy the config from section 3 and the test file from section 4. On iOS use platform: 'ios', bundleId: 'Settings', check for 'General' instead of 'Network & internet', and ask the agent to "go to General, then About".

4

Run, then run again

Run npx e2e run twice. Compare the duration and model calls of the second run with the first. Then add a third test that uses only screen and expect to open one more menu item with .click().

Model answer for step 4

The second run should replay the verified agent.act from the cache (look for replayed on the summary's Cache line), so it is faster and uses fewer tokens; agent.assert still calls the model. A deterministic third test has this shape:

test('opens a menu item', async ({ app, screen }) => { await app.open(); await screen.getByText('<menu item label>').click(); await expect(screen.getByText('<text on the next screen>')).toBeVisible(); });

Labels differ between Android and iOS versions and languages, so read them off your own device rather than copying them. If a locator fails, the screen.txt written for the failed attempt lists every node the engine saw, with its role and name (Module 5).

Troubleshooting
  • No devices found: boot one first, or check adb devices / xcrun simctl list devices booted.
  • simctl not found: install full Xcode and run sudo xcode-select -s /Applications/Xcode.app.
  • The runner seems stuck: npx agent-device daemon stop, then run again. The daemon also keeps the environment it started with, so stop it after changing ANDROID_HOME.
  • Timeouts on first launch: a cold simulator is slow. Boot it before the run.

Knowledge check

Pick one answer per question, then check your score.

1. Which tool does the e2e mobile engine use to drive simulators, emulators and phones?

Why: @e2e-dev/mobile is built on Callstack's agent-device; Playwright powers the web engine.

2. On a device, what does app.open() launch?

Why: There is no URL on a device; bundleId (bundle id or package name) identifies the app.

3. You set app.appPath to an .apk. When is it installed?

Why: appPath names the build; device.installApp() with no argument installs it, often from a fixture.

4. Your dev server runs on your laptop at port 8081, and the test runs on a physical Android phone. What do you do?

Why: On a phone, localhost is the phone itself.

5. Which web feature is not available on devices?

Why: A simulator has no portable session snapshot, so you sign in per test or seed the app. The cache still works, keyed on bundle id and screen title.

Self-check

Answer in your own words first, then open the model answer.

1. Why should every device test start with app.open()?

Between tests the app stays where the previous test left it. app.open() launches the target's app fresh, so each test starts from a known screen and the cache's screen-title anchor matches.

2. When would you use getByTestId instead of getByText on mobile?

When visible text changes with language, data or OS version, or when two elements share the same text. A test id (testID, accessibilityIdentifier, testTag, Semantics(identifier:)) is stable and set by the developers for testing.

3. Why set permissions in the config instead of letting the agent tap "Allow"?

Permissions in app.permissions are set before launch, so no system prompt appears. That is faster, costs no model calls, and makes the test deterministic.

References

  1. e2e documentation: Mobile, Mobile engine reference, EAS Simulators, Continuous integration, Example projects. Accessed 9 October 2026.
  2. Callstack, agent-device: github.com/callstack/agent-device (0.21.22 in our run).
  3. Android Developers: Run apps on the Android Emulator; Android Debug Bridge (adb).
  4. Apple Developer: Running your app in Simulator or on a device.

Image credits

Terminal and emulator screenshots are from runs made for this course on an Android emulator (Pixel 3a, API 34). The diagram was drawn for this course.

Summary

Key takeaways

  • @e2e-dev/mobile drives iOS simulators, Android emulators and real phones through agent-device, with the same app, screen, agent and expect API as the web.
  • Check your setup with npx agent-device doctor before writing tests.
  • A mobile target names the app with bundleId; appPath plus device.installApp() installs a build.
  • The device fixture, launch arguments and permissions control the device without model calls.
  • Device runs are slower; no URLs and no saved sessions, so start each test with app.open().