Skip to main content

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, .ipa and simulator .zip paths are uploaded to TestingBot Storage automatically. Already-uploaded builds can be referenced with their tb:// URL.
  • The keys are android, android-emulator, android-real, ios, ios-simulator and ios-real. The most specific one wins: ios-real overrides ios.
  • 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 (mobilewright unless you set one) and gets a run summary in its status message instead, such as 12/12 tests passed, or 1 of 3 tests failed. ... with the individual failure messages appended.
  • Set sessionPerTest: true for 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 own extra rather than replacing it, and the branch is added as a second tag. Enable it with captureGitInfo: { commit: true } in mobilewright.config.ts.
  • Failure messages are reported to the session's status message. build groups sessions on the dashboard and is auto-detected from GitHub Actions, GitLab, CircleCI, Buildkite, Bitrise, Travis, Azure DevOps, Jenkins and TeamCity, or read from a TESTINGBOT_BUILD variable.

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.
  • pressButton on iOS supports HOME, VOLUME_UP and VOLUME_DOWN. listApps() reports the foreground app only.
  • Screenshots are always PNG. applyDeviceSettings turns 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