Mobile Test YAML
Mobile tests use the same YAML shape as UI tests, with mobile-specific actions and selectors. Same steps list, same {{variable}} substitution rules, same assertion patterns.
Minimal example
steps:
- action: tap
selector: accessibility:test-Username
- action: type
selector: accessibility:test-Username
value: standard_user
- action: tap
selector: accessibility:test-Password
- action: type
selector: accessibility:test-Password
value: secret_sauce
- action: tap
selector: accessibility:test-LOGIN
- action: assert_visible
selector: accessibility:test-PRODUCTS
Selectors
Mobile uses Appium's selector syntax — same as Appium docs.
| Prefix | Means | Example |
|---|---|---|
accessibility: | Accessibility ID (preferred) | accessibility:test-Username |
id: | Native resource id (Android) / accessibility id (iOS) | id:com.example:id/username |
xpath: | XPath | xpath://*[@text='Login'] |
class: | Class name | class:android.widget.Button |
predicate: | iOS NSPredicate (iOS only) | predicate:label == 'Login' |
classchain: | iOS class chain (iOS only) | classchain:**/XCUIElementTypeButton[1] |
text: | Visible text, matched exactly | text:Don't allow |
Always prefer accessibility IDs — they're stable across UI redesigns and work the same on Android + iOS when developers tag elements correctly.
Matching on text
text: matches the whole label, not part of it — text:Allow will not match a Don't allow
button. Write the label as you'd type it; quote style and spacing are handled for you:
- Curly quotes and straight ones are treated as the same character. Android renders Don't allow
with a curly apostrophe, but
text:Don't allowmatches it. The two look identical on screen and in a bug report, so you should never have to care which one the app used. - Non-breaking spaces match ordinary spaces.
- Leading and trailing spaces are ignored, and runs of spaces count as one.
Android and iOS
accessibility: means the same thing on both platforms — it maps to content-desc on Android and to
the accessibility identifier on iOS. It's the selector to reach for if you want one test to run on
both.
text: matches what's on screen, and the two platforms store that differently. Android keeps it in
text; iOS keeps it in label, or in value for a field showing its contents. The platform is read
from the running session, so you write text:Sign In either way and we match the right attribute.
On iOS, text: deliberately does not match the accessibility identifier. In BrowserStack's own
sample app there's a button whose identifier is Text Button and whose visible label is Text — so
text:Text finds it and text:Text Button does not. That's on purpose: matching identifiers too
would let a selector quietly grab a control whose label reads something else entirely.
predicate: and classchain: are iOS-only. Used in an Android test they raise an error naming the
problem, rather than silently matching nothing.
Text is the most fragile thing to match on — it changes with copy edits and breaks in every other
language. Use it when there's nothing better, and prefer accessibility:.
Actions
Action names are case-insensitive (tap == TAP). Several have aliases — both spellings
execute identically.
It used to be reported SKIPPED, so a typo produced a green-looking run that had tested nothing.
Since release-repo #604 the step fails and says so:
Unsupported action: tapp — not executed. A declared step must run or fail, never silently skip.
Author from this table or, better, from the live catalog: GET /api/mobile-tests/actions. It drives
autocomplete in the test editor, and a build-time check keeps it, the validator and the agent's
dispatch switch in agreement — so an action you can discover is one that runs.
| Action | Aliases | What it does | Required fields |
|---|---|---|---|
launch_app | — | Launch / activate the app under test | — |
close_app | — | Terminate the app | — |
tap | click | Tap the element | selector |
type | send_keys | Clear the field, then type into it | selector, value |
clear | — | Clear a text field | selector |
long_press | — | Press-and-hold the element | selector |
hide_keyboard | — | Hide the soft keyboard if shown | — |
swipe_up | — | Swipe up | — |
swipe_down | — | Swipe down | — |
swipe_left | — | Swipe left | — |
swipe_right | — | Swipe right | — |
scroll_to_element | — | Scroll until the element is visible | selector |
navigate_back | — | Device back button | — |
wait_for_element | — | Wait until the element appears | selector, timeout |
wait_for_visible | — | Wait until the element is visible | selector, timeout |
wait_for_invisible | — | Wait until the element is gone (or was never there) | selector, timeout |
wait_for_clickable | wait_for_tappable | Wait until it can actually be tapped, not merely drawn | selector, timeout |
wait_for_text | — | Wait until the element's text contains the expected value | selector, expected, timeout |
double_tap | double_click | Two taps in quick succession | selector |
wait | wait_seconds, sleep | Pause. Prefer a real wait above — a fixed pause is slow when too long and flaky when too short | timeout |
validate_text | assert_text | Assert element text contains the expected value | selector, expected |
validate_contains | assert_contains | Same as above, explicit contains | selector, expected |
validate_visible | assert_visible | Fail if element not displayed | selector |
validate_not_visible | assert_not_visible | Fail if element IS displayed | selector |
validate_enabled | assert_enabled | Fail if element not enabled | selector |
get_text | — | Read the element's text (logged) | selector |
take_screenshot | — | Capture the screen | — |
value vs expected
value— data for input actions (type)expected— assertion text for validation actions (validate_text,validate_contains).expectedValueis accepted as an alias and takes precedence when both are set.
validate_text uses contains matching, not equals — consistent with UI testing. Scope the
selector tighter if you need a stricter check.
Swiping — the direction goes in value, never in direction
There is no direction: field. swipe exists and reads the direction from value:
- action: swipe
value: down # up | down | left | right — defaults to up
The four explicit actions do the same thing and read better:
- action: swipe_down
- action: swipe_up
- action: swipe_left
- action: swipe_right
Writing direction: down does not fail — the field is ignored and the swipe defaults to up, so
the test does something other than what it says. Prefer the explicit actions.
Webviews — seeing inside a hybrid app
If part of your app is a web page — a React Native web screen, Cordova, an in-app browser, an embedded checkout or consent page — its elements are not in the native tree. A locator that is obviously right when you look at the screen finds nothing, and that looks exactly like a bad selector.
Switch context first:
- action: wait_for_visible # make sure the screen hosting it has loaded
selector: "id:checkout_container"
- action: switch_context
value: WEBVIEW # first webview; NATIVE_APP goes back
- action: tap
selector: "css:#pay-now" # now a web selector works
- action: switch_context
value: NATIVE_APP
WEBVIEW matches the first webview by prefix, so you do not have to write the package-suffixed name
the driver actually reports. list_contexts prints everything available — useful once, while
writing the test.
The refusal names what is available:
No context matching 'WEBVIEW'. Available: [NATIVE_APP]. A webview that is still loading
is not listed yet — wait for an element on the screen that hosts it before switching.
Waiting for a native element on the hosting screen first is the fix, as in the example above.
The app and the device
| Action | What it does | Required fields |
|---|---|---|
switch_context | Move between native and webview | value |
list_contexts | Log every context available right now | — |
open_deep_link | Open the app straight at a screen | value (the link); Android also options.package |
background_app | Send it away for N seconds, then bring it back | timeout (seconds, default 5) |
activate_app | Bring an app to the front, starting it if needed | value (package / bundle id) |
terminate_app | Stop an app | value (package / bundle id) |
rotate | PORTRAIT or LANDSCAPE | value |
press_key | A hardware or keyboard key by name | value (e.g. ENTER, BACK, SEARCH) |
open_notifications | Pull down the notification shade (Android) | — |
Deep links are the biggest lever on a mobile suite. Without one, every test walks the app from launch, so it is slow and it breaks when navigation changes — even when navigation is not what it is testing.
- action: open_deep_link
value: "myapp://orders/1234"
Backgrounding is how you test session expiry and state restoration:
- action: background_app
timeout: 30
- action: validate_visible
selector: "id:login_screen" # did it ask us to sign in again?
press_key with ENTER matters more than it sounds: plenty of screens only submit when the
keyboard's own return key is pressed, and there is no button to tap instead.
press_key and open_notifications are Android capabilities. On an iOS session they fail with a
message saying so, rather than doing nothing.
timeout units
wait and the wait_for_* actions read timeout. A value at or below 600 is treated as
seconds, anything larger as milliseconds — so timeout: 5 and timeout: 5000 both mean five
seconds. This is the same rule web tests use.
- action: wait
timeout: 5 # 5 seconds
- action: wait_for_element
selector: "accessibility:test-PRODUCTS"
timeout: 10000 # 10 seconds
Mobile used to stop at 100, while web stopped at 600. So timeout: 300 was five minutes in a
web test and three tenths of a second here.
The failure was quiet, which is what made it costly: a wait that collapses to a fraction of a second reports that it could not find the element, so it looks like a bad selector rather than a bad timeout. Somebody rewrites a locator that was fine all along.
Both now use 600. If you wrote a mobile timeout between 101 and 600 to work around the old behaviour, it now means seconds — check it still says what you meant.
Variables
Same {{variable}} and ${variable} syntax as the rest of the platform (powered by Apache Commons Text StringSubstitutor).
steps:
- action: type
selector: accessibility:test-Username
value: "{{username}}"
- action: type
selector: accessibility:test-Password
value: "{{password}}"
Values come from the environment's auth config or per-test variables. Never hardcode credentials in the YAML — see Credentials.
Backend conditions
Beside steps:, a test can declare a top-level mocks: block naming the backend responses it needs
for that run — an expired subscription, a 500, an empty list. See Mocking the Backend.
mocks:
- method: GET
path: /v1/subscription
status: 200
body: '{"tier":"expired"}'
steps:
- action: tap
selector: "accessibility:test-Account"
Capabilities
The platform builds the Appium capability dict from the mobile-app's config + the device tier. You can extend it via:
Per-app extra capabilities
In the mobile app's config (UI: Mobile Apps → Edit → Appium Capabilities):
{
"appium:autoGrantPermissions": true,
"appium:noReset": false,
"appium:newCommandTimeout": 120
}
Merged on top of platform defaults.
Vendor-specific options
For BrowserStack/Sauce Labs, the platform builds bstack:options / sauce:options automatically from credentials + cloud_config. You can override individual keys via:
{
"bstackOptions": {
"debug": true,
"networkLogs": false,
"buildName": "release-v3.2"
}
}
These get nested under bstack:options in the final capability dict.
Common patterns
Login flow
steps:
- action: type
selector: accessibility:username-field
value: "{{username}}"
- action: type
selector: accessibility:password-field
value: "{{password}}"
- action: tap
selector: accessibility:login-button
- action: wait_for_element
selector: accessibility:home-screen
timeout: 15000
Scroll-and-find
steps:
- action: scroll_to_element
selector: accessibility:settings-link
- action: tap
selector: accessibility:settings-link
Form input with validation
steps:
- action: clear
selector: accessibility:email-field
- action: type
selector: accessibility:email-field
value: "invalid"
- action: tap
selector: accessibility:submit-button
- action: assert_visible
selector: accessibility:email-error
- action: assert_text
selector: accessibility:email-error
expectedText: "valid email"
What's different from UI testing
| UI tests | Mobile tests | |
|---|---|---|
| Engine | Playwright / Selenium / HtmlUnit | Appium (UiAutomator2 / XCUITest) |
| Selector syntax | CSS / XPath | accessibility: / id: / xpath: (Appium-style) |
home / back actions | N/A | Native nav |
swipe | rare | common |
| App context | URL-based | Package or pre-installed |
Element discovery
Use Inspect Elements on the mobile app's detail page to crawl screens and capture available accessibility IDs. Saves accidental selector drift when the app's resource IDs change.
Tips
- Lean on accessibility IDs — they survive UI redesigns.
- Use
wait_for_elementaggressively — mobile UIs animate, andtapon an off-screen element fails.wait_for_elementmakes tests stable. - Keep tests short — < 20 steps per test. Long tests are harder to debug.
- Tag step results with screenshots — adds
action: take_screenshotafter critical state transitions. Auto-attached to the run result for triage.
Related
- Local emulator — set up Appium locally to develop tests
- Device farms — run the same YAML on real BrowserStack devices
- Credentials —
{{username}}/{{password}}come from the vault