Locators and Page Objects
Locators
screen() returns locators. A locator is a lazy query: creating it does not touch the device; every action or read evaluates it against a fresh view tree.
| Method | Matches |
|---|---|
getByRole(MobileRole.BUTTON), getByRole(role, "name") | native classes of the role on both platforms; name is the label, else the text |
getByText("Save"), getByText("sav", false), getByText(Pattern) | text, else label, else value (exact, case-insensitive substring, or regex) |
getByLabel(...) | accessibility label |
getByTestId("login") | iOS accessibility identifier or Android resource-id (login also matches com.app:id/login) |
getByPlaceholder(...) | placeholder text |
getByType("android.widget.Switch") | the raw native class |
locate("role=button;name=Sign in") | a selector string |
Refine with first(), last(), nth(i), within(container), or(other), and(other), filterHasText(...), filterHasNotText(...), filterHas(locator), filterHasNot(locator) and withTimeout(Duration).
Locators in code
MobileLocator has static factories named like Appium's and the web Locator's: id(...), accessibilityId(...), xpath(...), iosClassChain(...), iosPredicate(...), className(...), byTestId, byText, byLabel, byPlaceholder, byRole(role[, name]) and of(selectorString). They bind to the current test's device each time they are used, so a static final field is safe in parallel runs. Used on a thread without an open device they fail with MobileException.
private static final MobileLocator SIGN_IN = MobileLocator.xpath("//*[@text='Sign in']");
SIGN_IN.tap();
Prefer test ids (accessibilityIdentifier on iOS, resource-id on Android): they do not change with the device language. Roles with a name come next; texts last.
Selector strings
@MobileFind and screen().locate(...) take one string per element:
| String | Same as |
|---|---|
LoginScreen.loginButton (no =) | getByTestId("LoginScreen.loginButton") |
testid=com.app:id/btnLogin | getByTestId("com.app:id/btnLogin") |
text=Sign in | getByText("Sign in") (exact) |
label=Password | getByLabel("Password") |
placeholder=Username | getByPlaceholder("Username") |
type=XCUIElementTypeSwitch | getByType("XCUIElementTypeSwitch") |
role=button / role=button;name=Sign in | getByRole(MobileRole.BUTTON[, "Sign in"]) |
id=com.app:id/btnLogin | same as testid= (Appium id) |
accessibility=loginButton | iOS identifier, name or label; Android content-desc (Appium accessibilityId) |
xpath=//android.widget.Button[@text='Sign in'] | XPath 1.0 over the view tree |
chain=**/XCUIElementTypeCell[`label == 'Orders'`] | iOS class chain (common subset) |
predicate=label BEGINSWITH 'Sign' | iOS NSPredicate (common subset) |
XPath, class chain and predicate run in TestFly on the tree mobilecli returns; mobilecli has no such queries. Limits:
- XPath: elements are named
XCUIElementType…(iOS) or by their Android class under ahierarchyroot. Attributes:resource-id,content-desc,text,name,label,value,placeholder,type,class,enabled,visible,selected,checked,focused,x,y,width,height. mobilecli drops unlabeled containers, so full-path XPaths copied from Appium Inspector may not match; XPaths by id, text or type do. - Class chain:
/-separated segments,**/for any depth, a type (with or withoutXCUIElementType) or*, and filters[`predicate`]or[n](1-based, negative from the end). - Predicate:
==,!=,BEGINSWITH,ENDSWITH,CONTAINS,LIKE,MATCHESwith[c]/[d], joined byAND,OR,NOTand parentheses, onname,label,value,type,identifier,text,placeholderValueand the state flags.
An unknown key, an unknown role or an empty value fails with MobileLocatorException, listing the valid formats.
Page objects
Pages extend MobilePage. A @MobileFind field holds the iOS and the Android selector of one element, like Appium's @iOSXCUITFindBy and @AndroidFindBy pair. Keep fields private and give tests intent methods; a method that changes the screen returns the next page.
public class HomePage extends MobilePage {
@MobileFind(ios = "HomeScreen.loginButton", android = "com.example:id/btnHome")
@WaitFor // new HomePage() waits until it is visible
private MobileLocator loginButton;
@MobileFind(ios = "cookieBannerAcceptAllButton")
private MobileLocator cookieAccept;
@Override
protected void onLoad() { // runs after the @WaitFor fields are visible
tapIfShown(cookieAccept);
}
public LoginPage openLogin() {
loginButton.tap();
return new LoginPage();
}
}
public class LoginPage extends MobilePage {
@MobileFind(ios = "LoginScreen.nameTextField", android = "com.example:id/etUser")
@WaitFor
private MobileLocator username;
@MobileFind("role=button;name=Sign in") // value: same selector on both platforms
private MobileLocator signIn;
public LoginPage signIn(String user) { username.fill(user); signIn.tap(); return this; }
public void shouldBeClosed() { expect(username).toBeHidden(); }
}
new HomePage().openLogin().signIn("ada").shouldBeClosed();
What new SomePage() does, in order:
- takes the current test's device (
MobilePage(MobileScreen)takes a screen explicitly, e.g. outside a test runner); - sets every
@MobileFindfield, superclasses included, to a locator for the running platform:ios/android, elsevalue; - waits for every
@WaitForfield to be visible, withintimeouts.actionMs; - calls
onLoad().
Rules:
-
Do not give
@MobileFindfields an initial value (= null): Java runs subclass field initializers after the base constructor and would overwrite the locator.onLoad()runs before those initializers. -
A field without a selector for the running platform still gets a locator, which fails when used ("LoginPage.username: no android selector"). Pages can therefore hold platform-only elements.
-
Fields must be non-static
MobileLocators; another type fails when the page is created. -
tapIfShown(locator)taps an element that is visible now and waits for it to disappear; it returnsfalseat once when the element is not shown. -
Creating a page waits for its screen. In Cucumber, keep page instances in step-class fields between steps instead of creating a page whose screen has already gone.
-
Instead of a selector string, a field can use one typed attribute per platform, as Appium's
@iOSXCUITFindBy(accessibility = …)does:iosAccessibility,iosId,iosXpath,iosClassChain,iosPredicate,iosClassName,androidAccessibility,androidId,androidXpath,androidClassName. Setting two for one platform (sayandroidandandroidId) fails when the page is created.@MobileFind(iosAccessibility = "LoginScreen.loginButton", androidId = "com.example:id/btnLogin")
private MobileLocator login; -
Plain classes work too:
page(Type.class)(fromMobileSupport) orMobilePages.init(page, screen)fill@MobileFindfields without the wait andonLoad().
The example pages are src/test/java/io/testfly/examples/mobile/HomePage.java and LoginPage.java.
YAML locator files
Locators can also live in a YAML file, one file for both platforms. Values are selector strings:
# src/test/resources/mobile-locators/login.yml
login:
username:
ios: LoginScreen.nameTextField
android: com.example:id/etUsername
signIn: role=button;name=Sign in # a plain value: both platforms
welcome:
mobile: text=Welcome back # mobile: both platforms
MobileLocators locators = MobileLocators.load("mobile-locators/login.yml"); // classpath, else file path
locators.get("login.username").fill("ada"); // the running platform's selector
- Groups nest; keys join with dots (
login.username). A map with onlyios,androidandmobilekeys is one locator;ios/androidwin overmobile. - Every selector is checked when the file loads; an invalid one fails naming its key. An unknown key fails listing the keys.
- A key without a selector for the running platform yields a locator that fails when used, as with
@MobileFind. get(key)uses the current test's device;get(key, screen)takes a screen explicitly. Load a file once (for example in astatic finalfield) and share it: it holds no device state.- Example:
src/test/resources/mobile-locators/login.yml.
Preconditions
Shared setup, such as signing in, belongs in a precondition instead of every test. Write the provider with page objects in a MobileConditions subclass and ask for it with @PreCondition:
public class LoginConditions extends MobileConditions {
@ConditionProvider("loggedIn")
public void loggedIn() {
new HomePage().openLogin().signIn(System.getenv("MOBILE_LOGIN_USER"), System.getenv("MOBILE_LOGIN_PASSWORD"));
}
}
@PreCondition("loggedIn")
@Test
public void showsTheBalance() { /* starts signed in */ }
- Register providers in
src/test/resources/META-INF/services/io.testfly.mobile.MobileConditions(one class name per line) or callMobileConditions.register(new LoginConditions())before the suite. - When to run: app state cannot be cached like browser cookies. With
mobile.app.reset: nonethe app keeps its state, so a condition runs once per device; withrelaunchorclearDatait runs before every test that asks for it. A retry always runs it again. - TestNG reads
@PreConditionfrom the test method; JUnit 5 from the method, then the class. Cucumber does not read it: use aBackground. - On a mobile test,
@PreConditionnames refer toMobileConditionsproviders only; webBaseConditionsproviders serve browser tests. - Example:
src/test/java/io/testfly/examples/mobile/LoginConditions.java.
Actions and assertions
- Actions:
tap(),doubleTap(),longPress(),fill(text)(same astype(text); clears first),append(text),clear(),swipe(SwipeDirection),pinchOut()/pinchIn()(zoom in / out, centred on the element) anddragTo(target). They wait until exactly one element matches and it is visible, enabled and stable, then act on the centre of the element. Several matches fail unlessfirst(),last()ornth(i)picks one (strict mode). - Scrolling:
scrollIntoView()swipes up (orscrollIntoView(SwipeDirection)) until exactly one match is visible; it fails at the end of the list or whentimeouts.actionMsruns out. - Reads:
getText(),getValue(),getAttribute(name)andnode()wait for a visible element;getAttributetakesresource-id,content-descandclassas aliases and returnsnullfor a missing attribute.count(),nodes(),allTexts(),isVisible(),isPresent(),isHidden(),isEnabled(),isDisabled(),isSelected(),isChecked()andisFocused()read the current screen without waiting; theis…state reads fail on several matches (strict).waitForVisible()andwaitForHidden()wait explicitly. - Assertions:
expect(locator)withtoBeVisible(),toBeHidden(),toBeEnabled(),toBeDisabled(),toBeSelected(),toBeChecked(),toBeFocused(),toHaveText(...),toContainText(...),toHaveValue(...),toHaveCount(n), andnot(). They retry untiltimeouts.actionMsruns out. - Device:
device()offerslaunchApp,terminateApp,clearAppData,installApp,openUrl(deep links),pressButton("BACK"),screenSize()andscreenshot();screen().swipe(...)swipes across the screen. - Device settings:
device().setOrientation(MobileOrientation.LANDSCAPE)andorientation();setLocation(latitude, longitude)andclearLocation(). Both are undone when the device closes, so the next test starts in the original orientation with the real location.startRecording(path)/stopRecording()record the screen by hand; automatic recording isrecordVideo.setAnimations(false)andsetAppearance("dark")(also asanimations/appearancein the config). - Clipboard and files:
clipboard(),setClipboard(text),clearClipboard();pushFile(localPath, "/sdcard/Download/photo.jpg")andpullFile(remotePath, localPath)(Android and iOS simulator, at most 1 MB per file). - Apps and keyboard:
isAppInstalled(id),foregroundApp(),uninstallApp(id),backgroundApp(Duration)(home screen, wait, back to the app);isKeyboardShown()andhideKeyboard()(Android presses BACK; iOS taps the keyboard's Done/Return key). - Crash reports:
crashReports()andcrashReport(id). When a mobile test fails, the crash reports written during the test are saved totarget/reports/crashes/and logged as a step. - Webviews:
device().webViews()lists the webviews of the app in front (and visible Chrome/Safari tabs);webView()returns the one visible webview.MobileWebViewoffersurl(),title(),content(),evaluate(js),query(css),navigate(url),reload(),goBack(),goForward()andwaitForLoad().
Actions are logged as report steps ("Tap getByText(…)"); typed text is not logged. When a test fails, a device screenshot is attached (screenshotOnFailure).