Lesson 3 of 6
Lesson 03 — Finding Elements: Locators and Stable Tests
Title: Accessibility id first; XPath as a last resort
Description: Learn how to find elements in your mobile app reliably — which locator strategies to use, how to choose between them, how to use Appium Inspector to discover element attributes, and how to organize your locators using the Page Object Model.
Why it matters for QA: Poorly chosen locators are the #1 reason test suites become hard to maintain. When the UI changes slightly, XPath-heavy tests break everywhere. Tests built with stable locators survive UI changes and take minutes to fix instead of days.
1. What is a locator?
Your test needs to interact with elements in your app — buttons, text fields, labels, lists. But how does Appium know which element you mean?
A locator is the strategy your test uses to find a specific element in the app's UI tree.
Think of the app's UI like an HTML page. Every element has attributes — a name, a type, maybe an ID. A locator is how you say: "Find the element with this attribute."
App screen (in memory):
┌─────────────────────────────────┐
│ ┌─────────────────────────────┐│
│ │ Email field ││ ← element with resource-id="email_input"
│ └─────────────────────────────┘│
│ ┌─────────────────────────────┐│
│ │ Password field ││ ← element with resource-id="password_input"
│ └─────────────────────────────┘│
│ ┌──────────┐ │
│ │ Log In │ │ ← element with accessibility id="loginButton"
│ └──────────┘ │
└─────────────────────────────────┘
Your test uses a locator to say:
"Find the element where accessibility id = 'loginButton', then click it"
2. Why locator choice matters
Not all locators are equal. Some are:
- Stable — they don't change when the developer moves a button or changes text
- Fragile — they break every time the UI layout changes
Choosing the wrong locator type means your tests break constantly, forcing you to spend more time fixing tests than finding real bugs.
The maintenance cost test: Ask yourself — "If the developer reorganizes this screen, will my locator still work?"
Locator durability spectrum:
Most durable Least durable
│ │
▼ ▼
accessibility id → resource-id / name → class name → XPath
A good rule of thumb: if your test suite uses more than 20% XPath, you have a maintenance problem waiting to happen.
3. The locator hierarchy — best to worst
Strategy 1: accessibility id — your first choice
What it is: A human-readable label attached to an element specifically for accessibility tools (screen readers for visually impaired users) and test automation.
- On Android, this maps to the element's
content-descattribute - On iOS, this maps to the element's
accessibilityIdentifieroraccessibilityLabel
Why it's the best: It's semantically meaningful (descriptive name, not a path), it works the same way on both platforms, and it's explicitly meant for accessibility/testing — so developers understand its purpose.
// Find the login button by its accessibility id
const loginButton = await driver.$('~loginButton');
await loginButton.click();
// The tilde (~) is the shorthand for "accessibility id" in WebdriverIO
// Equivalent to:
const loginButton2 = await driver.$('[accessibility id="loginButton"]');
How to set it up (ask your developers):
// Android (Kotlin) — developer adds this to the XML layout or code
button.contentDescription = "loginButton"
// or in XML:
// android:contentDescription="loginButton"// iOS (Swift) — developer adds this in code or Interface Builder
loginButton.accessibilityIdentifier = "loginButton"Important: Work with your developers to add accessibility identifiers to key elements. This benefits both your tests AND users who rely on screen readers.
Strategy 2: resource-id (Android) / name (iOS)
What it is:
- On Android: the element's
resource-id— a unique ID defined in the app's layout (e.g.com.example.app:id/loginButton) - On iOS: the element's
nameattribute, which is usually the element's label text
Why it's good: Resource IDs are defined in code and tend to be stable. Developers rarely rename IDs compared to changing text or layout.
// Android — find by resource-id using Android UIAutomator selector
const loginButton = await driver.$('android=new UiSelector().resourceId("com.example.app:id/login_button")');
// Android — shortcut using the id strategy
const emailField = await driver.$('#com.example.app:id/email_input');
// Note: the "#" prefix means "by id" in some Appium clients
// iOS — find by name (label text)
const continueButton = await driver.$('Continue');
Strategy 3: class name
What it is: The type of UI element — android.widget.Button, XCUIElementTypeTextField, etc.
When to use it: Useful for finding all elements of a type (e.g. all buttons on a screen), but usually too broad for identifying a specific element.
// Find all buttons on screen (Android)
const allButtons = await driver.$$('android.widget.Button');
console.log(`Found ${allButtons.length} buttons`);
// Find the first text field (iOS)
const firstTextField = await driver.$('XCUIElementTypeTextField');
Limitation: Most screens have multiple buttons or text fields. Class name alone doesn't tell you which one you want. Use it in combination with other attributes when needed.
Strategy 4: XPath — last resort only
What it is: A path-based query that traverses the element tree, similar to how XPath works in XML/HTML.
Why it should be your last choice:
-
Fragile: XPath describes position in the tree structure. When a developer adds a wrapper view or reorders elements, your XPath path breaks — even if the target element didn't change at all.
-
Slow: XPath forces the driver to scan the entire element tree to find matches. On complex screens with many elements, this can take seconds per lookup.
-
Hard to read:
//android.widget.LinearLayout[3]/android.widget.RelativeLayout/android.widget.Button[1]tells you nothing about what the element is.
// AVOID — absolute XPath (breaks with any layout change)
const fragileLocator = await driver.$(
'//android.widget.LinearLayout[3]/android.widget.RelativeLayout/android.widget.Button[1]'
);
// BETTER — if you must use XPath, be specific about the attribute
const betterLocator = await driver.$(
'//android.widget.Button[@content-desc="loginButton"]'
);
// BEST — don't use XPath at all for this, use accessibility id instead
const bestLocator = await driver.$('~loginButton');
The one place XPath is acceptable: When you need to find an element based on its relationship to another element or by its text content when no ID or accessibility label is available.
// Finding an element by its visible text (when no better option exists)
const submitButton = await driver.$(
'//android.widget.Button[@text="Submit Order"]'
);
// Finding a child element near a known parent
const productPrice = await driver.$(
'//android.widget.TextView[@content-desc="productCard"]//following-sibling::android.widget.TextView[1]'
);
4. Locator summary table
| Strategy | Android selector | iOS selector | Stability | Speed |
|---|---|---|---|---|
| Accessibility id | ~myId | ~myId | Excellent | Fast |
| Resource ID | #com.app:id/btn or UIAutomator | name attribute | Good | Fast |
| Class name | android.widget.Button | XCUIElementTypeButton | Poor (too broad) | Fast |
| XPath | //android.widget... | //XCUIElementType... | Poor | Slow |
5. Using Appium Inspector to find locators (step-by-step)
Appium Inspector is your best tool for discovering which attributes an element has before writing locator code.
Prerequisites:
- Appium server running (
appiumin terminal) - Device/emulator running with your app open
- Appium Inspector installed (download from github.com/appium/appium-inspector)
Step-by-step:
Step 1: Open Appium Inspector and enter your capabilities in the JSON editor:
{
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "Pixel_6_API_33",
"appium:appPackage": "com.example.myshop",
"appium:appActivity": "com.example.myshop.MainActivity",
"appium:noReset": true
}Step 2: Click "Start Session". Inspector connects to your app and takes a screenshot.
Step 3: Click any element in the screenshot. The right panel shows all its attributes:
Selected element attributes:
content-desc: "loginButton" ← use as accessibility id
resource-id: "com.example:id/btn_login" ← use as resource id
class: "android.widget.Button" ← element type
text: "Log In" ← visible text
bounds: "[100,400][980,500]" ← position on screen
Step 4: Look for content-desc or accessibility identifier first. If it has a meaningful value, that's your locator.
Step 5: Use the "Search for element" bar in Inspector to test your locator before writing it in code. If it highlights the correct element, your locator works.
Important: Inspector copies XPath by default when you click "Copy XPath". This is convenient but don't use it blindly — check first if there's a better attribute to use.
6. Writing safe XPath when you have no choice
When accessibility IDs are missing and resource IDs aren't available, here's how to write XPath that won't break with small changes:
Rule 1: Target attributes, not positions
// FRAGILE — breaks if any element is added before this button
'//android.widget.Button[2]'
// STABLE — targets the specific attribute value
'//android.widget.Button[@text="Log In"]'
Rule 2: Keep XPath as short as possible
// FRAGILE — full path breaks with any layout change
'//android.widget.LinearLayout/android.widget.RelativeLayout/android.widget.FrameLayout/android.widget.Button'
// BETTER — use // to search anywhere in the tree, then be specific
'//android.widget.Button[@text="Log In"]'
Rule 3: Scope to a known ancestor when possible
// If you have a login form container with a known ID,
// scope your XPath to inside that container — not the whole screen
const loginForm = await driver.$('#com.example.app:id/login_form');
const submitButton = await loginForm.$('.//android.widget.Button[@text="Submit"]');
// The "./" means "search inside loginForm", not the whole tree
7. Page Object Model for mobile
As your test suite grows, you'll find yourself writing the same locators in multiple test files. When a developer renames an element, you have to update it everywhere. This is the problem the Page Object Model (POM) solves.
What is the Page Object Model?
POM is a design pattern where you create one class (or module) per screen. That class contains:
- All the locators for that screen (in one place)
- All the actions you can take on that screen (tap login, enter email, etc.)
Your test files then use these page objects instead of writing locators directly.
Why use POM:
- Locators live in one place — update once, fixed everywhere
- Tests read like human steps:
loginPage.tapLoginButton() - Test logic stays clean and readable
- Reusable actions reduce code duplication
In a well-designed test, the Page Object owns the implementation details. The
test calls a business-level method such as loginPage.login(...) and does not
need to know which selector, wait, or tap sequence is required internally.
Example: Login screen Page Object
// pages/LoginPage.js
// All locators for the login screen are defined here as constants.
// If a locator changes, you update it in ONE place only.
const SELECTORS = {
emailField: '~emailInput',
passwordField: '~passwordInput',
loginButton: '~loginButton',
errorMessage: '~loginErrorMessage',
forgotPasswordLink: '~forgotPasswordLink',
};
const DEFAULT_TIMEOUT = 5000;
export class LoginPage {
constructor(driver) {
this.driver = driver;
}
/**
* Enters email address in the email field.
* @param {string} email - The email to enter
*/
async enterEmail(email) {
const field = await this.driver.$(SELECTORS.emailField);
await field.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
await field.setValue(email);
}
/**
* Enters password in the password field.
* @param {string} password - The password to enter
*/
async enterPassword(password) {
const field = await this.driver.$(SELECTORS.passwordField);
await field.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
await field.setValue(password);
}
/**
* Taps the login button to submit credentials.
*/
async tapLoginButton() {
const button = await this.driver.$(SELECTORS.loginButton);
await button.waitForEnabled({ timeout: DEFAULT_TIMEOUT });
await button.click();
}
/**
* Returns the error message text shown after a failed login.
* @returns {Promise<string>} The error message text
*/
async getErrorMessage() {
const errorEl = await this.driver.$(SELECTORS.errorMessage);
await errorEl.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
return errorEl.getText();
}
/**
* Convenience method: fills in and submits the login form.
* @param {string} email
* @param {string} password
*/
async login(email, password) {
await this.enterEmail(email);
await this.enterPassword(password);
await this.tapLoginButton();
}
}
Example: Test file using the Page Object
// tests/login.test.js
import { remote } from 'webdriverio';
import { LoginPage } from '../pages/LoginPage.js';
const capabilities = {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Pixel_6_API_33',
'appium:app': '/absolute/path/to/app.apk',
};
describe('Login screen', () => {
let driver;
let loginPage;
beforeEach(async () => {
driver = await remote({ hostname: 'localhost', port: 4723, capabilities });
loginPage = new LoginPage(driver);
});
afterEach(async () => {
if (driver) await driver.deleteSession();
});
it('should log in with valid credentials', async () => {
await loginPage.login('user@example.com', 'SecurePass123');
// After login, verify we navigated to the home screen
const homeHeader = await driver.$('~homeScreenHeader');
await expect(homeHeader).toBeDisplayed();
});
it('should show error message with wrong password', async () => {
await loginPage.login('user@example.com', 'wrongpassword');
const errorText = await loginPage.getErrorMessage();
expect(errorText).toBe('Invalid email or password. Please try again.');
});
});
Notice how clean the test reads: loginPage.login(email, password) — no locators, no waits, just the test scenario. If the login button's locator changes tomorrow, you update SELECTORS.loginButton in LoginPage.js and all tests are fixed.
8. Common locator mistakes
Mistake 1: Using Appium Inspector's auto-generated XPath directly
Inspector generates XPath like:
//hierarchy/android.widget.FrameLayout[1]/android.widget.LinearLayout/...
This is absolute XPath — it includes every level of the tree. One layout change and it's broken. Always look for a better attribute in the element panel first.
Mistake 2: Using visible text as a locator for localized apps
// FRAGILE — if the app is translated to French, this breaks
const loginButton = await driver.$('//android.widget.Button[@text="Log In"]');
// BETTER — use accessibility id which doesn't change with language
const loginButton = await driver.$('~loginButton');
Mistake 3: Not waiting for elements before interacting
// WRONG — element might not be visible yet
const button = await driver.$('~submitButton');
await button.click(); // Might fail if screen is still loading
// CORRECT — wait for the element first
const button = await driver.$('~submitButton');
await button.waitForDisplayed({ timeout: 5000 });
await button.click();
Mistake 4: Scattering locators across test files
// WRONG — locator defined in the test file directly
it('should submit form', async () => {
const button = await driver.$('~submitButton'); // locator lives here
await button.click();
});
// If the locator changes, you must hunt through every test file.
// CORRECT — locator lives in the Page Object
it('should submit form', async () => {
await formPage.tapSubmitButton(); // locator is in FormPage.js
});
Mistake 5: Using array indexes without explanation
// WRONG — which button is index 1? Why that one?
const buttons = await driver.$$('android.widget.Button');
await buttons[1].click();
// If you must use an index, explain why and keep it local.
// BETTER — at minimum, add a comment
const addToCartButtons = await driver.$$('~addToCartButton');
const firstProductAddButton = addToCartButtons[0];
// Index 0 because the page is sorted newest-first and the scenario targets
// the newest product.
await firstProductAddButton.click();
Official docs
- Appium — Finding elements
- UiAutomator2 — Selector strategies
- Android accessibility docs — why accessibility labels help tests AND users
Quick recap
| Locator strategy | Use when | Avoid when |
|---|---|---|
~accessibilityId | Developer has set content-desc / accessibilityIdentifier | Label is generic or reused on screen |
resource-id | Element has a unique Android ID | ID contains dynamic numbers |
class name | Finding all elements of a type | You need one specific element |
| XPath by attribute | No ID/accessibility label; need text match | You have a better option |
| XPath by position | Absolute last resort | Almost never — use anything else |
| POM principle | Why it matters |
|---|---|
| One class per screen | Locators live in one place; update once |
| Private selector constants | Forces all access through methods |
| Methods describe actions | Tests read as user stories, not code |
No raw driver.$() in tests | Keeps test files clean and focused |
Practice exercises
-
Locator ranking exercise: For each element below, write the locator you would use and explain why:
- A "Submit" button with
content-desc="submitOrderButton"andresource-id="com.app:id/btn_submit" - A product price label with no accessibility id, no resource-id, but with
text="$29.99" - The 3rd item in a list where all items have the same class and no IDs
- A "Submit" button with
-
Inspector exercise: Open Appium Inspector on any app (even a simple demo app). Click on 5 different elements and record: (a) does it have an accessibility id? (b) does it have a resource-id? (c) what would you use as a locator?
-
Convert fragile XPath: Rewrite this XPath to be more stable:
//android.widget.LinearLayout[2]/android.widget.RelativeLayout[1]/android.widget.Button[1]Assume the button hastext="Add to Cart". -
Build a Page Object: Create a
HomePage.jspage object for an imaginary app home screen with these elements: a search bar (~searchBar), a list of products (~productList), and a cart icon (~cartIcon). Include methods:searchFor(text),openCart(), andgetProductCount(). -
Code review: Review this test and identify all the locator problems:
javascriptjavascript it('should add product to cart', async () => { const buttons = await driver.$$('android.widget.Button'); await buttons[2].click(); const cartText = await driver.$('//android.widget.TextView[3]'); expect(await cartText.getText()).toBe('1 item'); });
You now have the foundation for writing stable, maintainable mobile tests. The next step is combining what you know — capabilities, sessions, and good locators — into a complete test project with CI integration.