Mobilewright Testing
Mobilewright is an open source mobile UI test framework with a Playwright-style API. You write tests in TypeScript or JavaScript against device, screen and expect fixtures, and Mobilewright drives your iOS or Android app.
TestingBot maintains @testingbot/mobilewright-driver, which runs those same tests on our device cloud instead of a device attached to your laptop: real iPhones, iPads and Android phones and tablets in our European datacenter, plus iOS simulators and Android emulators.
- No local Appium server, Xcode or Android SDK required.
- Your app is uploaded and pre-installed on the device before the first test runs.
- Every device slot becomes a TestingBot session with video, command logs and device logs.
- Pass and fail verdicts, build names and git metadata are reported for you.
The driver requires Node.js 18 or newer and Mobilewright 0.0.53 or newer, the first release that accepts driver instances. Its peer dependency is @mobilewright/protocol >=0.0.53 <0.1.0, which Mobilewright installs for you.
Quickstart
The fastest way to start is the scaffolder. It asks a few questions and writes a mobilewright.config.ts plus a sample test into your project. Add --yes to accept the defaults.
npx @testingbot/mobilewright-driver init
To set things up by hand instead, install the packages as dev dependencies:
npm install --save-dev mobilewright @mobilewright/test @testingbot/mobilewright-driver
Credentials
The driver reads your TestingBot key and secret from the environment. You can find them on your member edit page.
export TESTINGBOT_KEY=your_key
export TESTINGBOT_SECRET=your_secret
TB_KEY and TB_SECRET are accepted as aliases. If you would rather not use environment variables, pass key and secret to the driver directly. Do not commit them to your repository.
Configuration
Point Mobilewright at TestingBot by passing a TestingBotDriver instance in your mobilewright.config.ts. Because TestingBot sessions start with the app already installed, the app under test has to be declared up front in the apps option.
import { defineConfig } from 'mobilewright';
import { TestingBotDriver } from '@testingbot/mobilewright-driver';
export default defineConfig({
testDir: '.',
bundleId: 'com.example.MyApp',
driver: new TestingBotDriver({
apps: {
android: './build/app.apk',
'ios-simulator': './build/app-sim.zip',
'ios-real': './build/app.ipa', // must be test-signed for real devices
},
}),
projects: [
{ name: 'android', use: { platform: 'android', deviceType: 'emulator' } },
{ name: 'ios', use: { platform: 'ios', deviceType: 'real', osVersion: '>=17' } },
],
});
A few things worth knowing about apps:
- Local
.apk,.ipaand simulator.zippaths are uploaded to TestingBot Storage automatically. Already-uploaded builds can be referenced with theirtb://URL. - The keys are
android,android-emulator,android-real,ios,ios-simulatorandios-real. The most specific one wins:ios-realoverridesios. - A value can be an array. The first entry is the app under test, the rest are installed alongside it, which is how you ship a mock server or a test harness with your build.
A Mobilewright test looks the same whether it runs locally or on TestingBot:
import { test, expect } from '@mobilewright/test';
test('shows the welcome screen', async ({ device, screen, bundleId }) => {
await device.launchApp(bundleId);
await expect(screen.getByText('Welcome')).toBeVisible();
});
Selecting devices
Device selection comes from the Mobilewright project config, in the use block. Regular expressions and version ranges work on both real and virtual devices.
| Key | Example | Notes |
|---|---|---|
platform |
'ios' or 'android'
|
Required. |
deviceType |
'real', 'simulator', 'emulator'
|
Defaults to a virtual device. |
deviceName |
/iPhone 1[45]/, /pixel/i
|
Regex, matched against the TestingBot device catalogs. |
osVersion |
'17', '17.4', '>=16 <18'
|
Exact version, prefix or range expression. |
deviceId |
'2241' |
Pin one specific physical device. The id comes from GET /v1/devices. The Mobilewright docs mark deviceId as local-driver-only; this driver resolves it against the TestingBot catalog, so it works here too. |
Real devices resolve against the live physical catalog, where idle devices are preferred. If the device you asked for is busy, TestingBot queues your session until it frees up rather than failing. Virtual devices resolve against the simulator and emulator catalog, where the newest matching OS version wins. See the supported device list for what is currently available.
Running tests
npx mobilewright test
Each device slot maps to a TestingBot session on your dashboard, with video and a pass or fail verdict reported automatically. Prefix the command with DEBUG=testingbot:* to print every allocation, hub command, upload and result the driver performs.
DEBUG=testingbot:* npx mobilewright test
Driver options
Everything the driver accepts. Only apps is required; the values below are examples, and the comments call out the real defaults. Anything left unset is simply not sent, in which case the TestingBot server-side default applies.
new TestingBotDriver({
apps: { android: './build/app.apk' }, // app under test, most specific key wins
// ('ios-real' beats 'ios'); tb:// URLs allowed.
// Array = app under test first, helper apps after:
// android: ['./app.apk', './mock-server.apk']
key: '...', // default: TESTINGBOT_KEY (or TB_KEY) env
secret: '...', // default: TESTINGBOT_SECRET (or TB_SECRET) env
sessionPerTest: false, // true = fresh session (and video) per test
name: 'checkout flow', // session name (default 'mobilewright')
hubUrl: '...', // default: https://hub.testingbot.com/wd/hub
apiUrl: '...', // default: https://api.testingbot.com/v1
build: 'ci-1234', // default: auto-detected from CI env / TESTINGBOT_BUILD
testResults: 'on', // 'off' disables pass/fail reporting
allocationTimeout: 395_000, // ms to wait for a device (default 395_000)
commandTimeout: 60_000, // ms per WebDriver command (default 60_000)
idleTimeout: 230, // seconds before TestingBot reaps an idle session (default 230)
maxDuration: 1800, // max session length in seconds (unset by default)
appiumVersion: '2.11.2', // pin the Appium version (unset = TestingBot's default)
timeZone: 'Europe/Brussels', // device timezone (tz database name)
geoCountryCode: 'DE', // route device traffic via a proxy in this country
throttleNetwork: '3G', // or { downloadSpeed, uploadSpeed, latency, loss }
autoGrantPermissions: true, // Android: auto-grant app permission dialogs
autoAcceptAlerts: true, // iOS: auto-accept system permission alerts
screenshots: true, // screenshot at every step (default false)
video: true, // session video (default true)
recordLogs: 'strip-parameters', // command logs: true | false | 'strip-parameters'
public: false, // make results publicly accessible
extra: 'commit=abc123', // custom metadata on the test detail page
tabletOnly: false, // restrict allocation to tablets
phoneOnly: false, // ...or to phones
capabilities: {}, // extra Appium capabilities (escape hatch)
tbOptions: {}, // extra tb:options entries (escape hatch)
});
capabilities and tbOptions are the escape hatches for anything the driver does not expose directly. See the Appium capabilities reference for what you can put in them.
TestingBot commands inside tests
Some TestingBot features are runtime commands rather than session capabilities. Import the testingbot helper and call it from a test body; it targets the device session Mobilewright connected for that test.
import { test, expect } from '@mobilewright/test';
import { testingbot } from '@testingbot/mobilewright-driver';
test('checkout survives a slow network', async ({ device, screen, bundleId }) => {
await device.launchApp(bundleId);
await testingbot.annotate('starting checkout'); // shows in the session timeline
await testingbot.throttle('3G');
await expect(screen.getByText('Order placed')).toBeVisible();
console.log(testingbot.dashboardUrl());
});
| Command | TestingBot command | What it does |
|---|---|---|
throttle(conditions) |
tb:throttle |
Network conditions mid-test: 'Edge', '3G', '4G', 'airplane', 'disable', or { downloadSpeed, uploadSpeed, latency, loss } in kb/s and ms. |
annotate(text) |
tb:test-context |
Logs a step into the session command list so the timeline shows what the test was doing. |
setName(name) |
tb:test-name |
Names the session on the dashboard. |
setBuild(build) |
tb:test-build |
Groups the session under a build. |
setTags([...]) |
tb:test-tags |
Tags the session. |
setResult(passed) |
tb:test-result |
Overrides the verdict, as a boolean or 'passed' / 'failed'. The driver reports it automatically, so use this only for outcomes Mobilewright cannot see. |
updateInfo({...}) |
tb:test-info |
Bulk update of name, build, public, statusMessage and extra. |
breakpoint() |
tb:break |
Pauses the session for manual inspection with a live view. Debugging only, never leave it in CI. |
shell(command, args) |
mobile: shell |
ADB shell on Android. A whitelisted subset is available on physical devices. |
execute(script, ...args) |
any | Escape hatch for any Appium or TestingBot execute-script command. |
sessionId() / dashboardUrl()
|
- | This test's TestingBot session id and dashboard link. |
Throttling is reset automatically when the test's device session is released, so slow-network conditions never leak into the next test that reuses the device. Calling a command outside a test body raises an explanatory error rather than a null reference.
Recipe: name every session after its test
test.beforeEach(async ({ device }, testInfo) => {
await testingbot.setName(testInfo.title);
await testingbot.setTags([testInfo.project.name]);
});
Requesting the device fixture is what makes the session exist before the hook runs. With sessionPerTest: true each session gets exactly one name; in pooled mode the last test to run on a session wins.
Sessions, naming and reporting
-
By default a device slot's session is reused across tests, so you pay device startup once. A session that hosted exactly one test is renamed after that test and carries its own verdict. A session that hosted several keeps its configured name (
mobilewrightunless you set one) and gets a run summary in its status message instead, such as12/12 tests passed, or1 of 3 tests failed. ...with the individual failure messages appended. -
Set
sessionPerTest: truefor one session, one video and one named verdict per test. That is the clearest dashboard, at the cost of device startup on every test. -
Every session is tagged
mobilewright, so you can filter the dashboard down to these runs. Git metadata is reported on top of that when Mobilewright captures it: the commit hash, branch, author and subject land in the session's extra field, alongside your ownextrarather than replacing it, and the branch is added as a second tag. Enable it withcaptureGitInfo: { commit: true }inmobilewright.config.ts. -
Failure messages are reported to the session's status message.
buildgroups sessions on the dashboard and is auto-detected from GitHub Actions, GitLab, CircleCI, Buildkite, Bitrise, Travis, Azure DevOps, Jenkins and TeamCity, or read from aTESTINGBOT_BUILDvariable.
Testing localhost and staging servers
If your app talks to a backend that is not reachable from the internet, use the TestingBot Tunnel. Either run the tunnel yourself and hand the driver its identifier:
driver: new TestingBotDriver({ tunnelIdentifier: 'my-tunnel' })
Or let the driver manage the tunnel around the run. This requires Java and npm install --save-dev testingbot-tunnel-launcher:
driver: new TestingBotDriver({ tunnel: true }) // anonymous tunnel
driver: new TestingBotDriver({ tunnel: { identifier: 'ci-42' } }) // named tunnel
The driver starts the tunnel before any device is allocated and closes it after the run.
Running in CI/CD
Store your credentials as secrets and run Mobilewright like any other test command. The build name is auto-detected, so no extra configuration is needed.
jobs:
mobile-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx mobilewright test
env:
TESTINGBOT_KEY: ${{ secrets.TESTINGBOT_KEY }}
TESTINGBOT_SECRET: ${{ secrets.TESTINGBOT_SECRET }}
# build name auto-detects as "owner/repo #run" - no config needed
The same pattern works on the other CI systems listed under CI/CD integrations.
How Mobilewright maps to TestingBot
| Mobilewright concept | TestingBot implementation |
|---|---|
| Device allocation | An Appium session on hub.testingbot.com. The session UUID is the device id, and busy devices queue server side. |
| App install | Uploaded to TestingBot Storage at allocation time and passed as appium:app, helper apps as appium:otherApps. Real iOS builds are re-signed automatically. There are no mid-session installs. |
| Taps, swipes and gestures | W3C pointer actions. |
| View hierarchy | Appium page source, mapped to Mobilewright's ViewNode tree. |
| Webviews | Appium contexts, through webViewBridge. |
| Pass and fail reporting |
PUT /v1/tests/:session from the TestObserver hooks. |
| Video | Recorded by TestingBot. startRecording({ output }) downloads the MP4 locally at run end. |
Troubleshooting
| Symptom | Cause and fix |
|---|---|
TestingBot credentials are missing |
Set TESTINGBOT_KEY and TESTINGBOT_SECRET, or pass { key, secret } to the driver. |
TestingBot sessions must start with an app |
Declare the app in the driver's apps option, per platform and device type. |
No TestingBot real device matches ... |
Your criteria matched nothing in the catalog. Check the available devices and loosen osVersion or deviceName. |
| Allocation waits for minutes | The matching device is busy. TestingBot queues the session until it frees up, bounded by allocationTimeout. |
Test timeout ... while setting up "device" |
More workers than your plan's parallel limit. TestingBot queues up to twice the limit server side and the driver waits out the rest. The driver logs your plan's number: set workers to at most that, or raise the Mobilewright timeout. |
... is a simulator-only build and cannot be installed on a real device |
Build a test-signed .ipa for deviceType: 'real' projects. |
"..." is not installed on this device |
The config's bundleId is not the package id of the build in apps. Check it with aapt2 dump packagename app.apk, and look for a stale bundleId or environment override. |
cannot install "..." mid-session |
TestingBot has no mid-session installs. Every app in installApps must also be listed in the driver's apps option. |
| Sessions show a run summary instead of a test name | The session hosted several tests, so the summary goes to the status message and the name is left alone. Use sessionPerTest: true to get one named session per test. |
Current limitations
- One TestingBot session may host several Mobilewright tests unless
sessionPerTest: true. Multi-test sessions report the run level verdict. - Modifier key chords such as
pressKeys(['ctrl+a'])work on Android only, because XCUITest cannot hold modifier keys. -
pressButtonon iOS supportsHOME,VOLUME_UPandVOLUME_DOWN.listApps()reports the foreground app only. - Screenshots are always PNG.
applyDeviceSettingsturns Android animations off on a best-effort basis and is a no-op on iOS. - Real iOS devices need a test-signed
.ipa. Simulator builds are rejected on real devices with a clear error. - While attached to a webview, the session's single Appium context is the web layer.
Frequently asked questions
What is Mobilewright?
Mobilewright is an open source mobile UI test framework from the mobile-next project. You write tests in TypeScript or JavaScript with a Playwright-style API (device, screen, expect) and run them against iOS and Android apps. TestingBot publishes @testingbot/mobilewright-driver, a driver that points those same tests at our device cloud.
How do I run Mobilewright tests on TestingBot?
Run npx @testingbot/mobilewright-driver init in your test project to scaffold a config and a sample test, export TESTINGBOT_KEY and TESTINGBOT_SECRET, then run npx mobilewright test. See the quickstart for the manual setup.
Can I run Mobilewright on real iOS and Android devices?
Yes. Set deviceType: 'real' in a project's use block to allocate physical iPhones, iPads and Android phones or tablets from our EU datacenter. Leave it out and you get an iOS simulator or Android emulator instead. Real iOS devices need a test-signed .ipa.
Do I have to upload my app before running?
No. Declare your builds in the driver's apps option and local .apk, .ipa and simulator .zip files are uploaded to TestingBot Storage for you. If you already uploaded a build you can reference its tb:// URL instead.
Why do I get "Test timeout while setting up device"?
You are running more Mobilewright workers than your plan's parallel limit. TestingBot queues up to twice the limit server side and the driver waits out the rest, so the test-scoped device fixture eventually times out. The driver logs your plan's parallel limit: set workers to at most that number, or raise the Mobilewright timeout.
Can I test a staging or localhost backend?
Yes. Pass tunnelIdentifier to reuse a TestingBot Tunnel you started yourself, or let the driver open and close one around the run with tunnel: true. See Testing localhost and staging.
Why does one session contain several tests?
By default a device slot's session is reused across tests so you only pay device startup once, and such a session reports a run level verdict in its status message, such as "12/12 tests passed". Set sessionPerTest: true for one session, one video and one named verdict per test.
How do I debug a failing Mobilewright run?
Prefix the run with DEBUG=testingbot:* to log every allocation, hub command, upload and result the driver performs. Every session also has video, command logs and device logs on its test detail page in the dashboard.
Next steps
- Driver on GitHub
Source, issues and releases - Supported devices
Real iPhones, iPads and Android devices on the grid - Real device testing
How the real-hardware cloud works - TestingBot Tunnel
Test against internal or staging APIs - Appium guide
The protocol the driver speaks underneath - Pricing
Real-device plans and concurrency