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-desc attribute
  • On iOS, this maps to the element's accessibilityIdentifier or accessibilityLabel

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.

javascriptjavascript
// 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):

kotlinkotlin
// Android (Kotlin) — developer adds this to the XML layout or code
button.contentDescription = "loginButton"
// or in XML:
// android:contentDescription="loginButton"
swiftswift
// 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 name attribute, 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.

javascriptjavascript
// 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.

javascriptjavascript
// 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:

  1. 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.

  2. 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.

  3. Hard to read: //android.widget.LinearLayout[3]/android.widget.RelativeLayout/android.widget.Button[1] tells you nothing about what the element is.

javascriptjavascript
// 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.

javascriptjavascript
// 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

StrategyAndroid selectoriOS selectorStabilitySpeed
Accessibility id~myId~myIdExcellentFast
Resource ID#com.app:id/btn or UIAutomatorname attributeGoodFast
Class nameandroid.widget.ButtonXCUIElementTypeButtonPoor (too broad)Fast
XPath//android.widget...//XCUIElementType...PoorSlow

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:

Step-by-step:

Step 1: Open Appium Inspector and enter your capabilities in the JSON editor:

jsonjson
{
  "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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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

javascriptjavascript
// 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


Quick recap

Locator strategyUse whenAvoid when
~accessibilityIdDeveloper has set content-desc / accessibilityIdentifierLabel is generic or reused on screen
resource-idElement has a unique Android IDID contains dynamic numbers
class nameFinding all elements of a typeYou need one specific element
XPath by attributeNo ID/accessibility label; need text matchYou have a better option
XPath by positionAbsolute last resortAlmost never — use anything else
POM principleWhy it matters
One class per screenLocators live in one place; update once
Private selector constantsForces all access through methods
Methods describe actionsTests read as user stories, not code
No raw driver.$() in testsKeeps test files clean and focused

Practice exercises

  1. Locator ranking exercise: For each element below, write the locator you would use and explain why:

    • A "Submit" button with content-desc="submitOrderButton" and resource-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
  2. 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?

  3. 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 has text="Add to Cart".

  4. Build a Page Object: Create a HomePage.js page 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(), and getProductCount().

  5. 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.