Skip to content

Implementing the Page Object Model (POM) ​

Implement the Page Object Model pattern to keep large flow suites maintainable.

As the number of flows grows, selectors show up in every flow file. Writing ids directly into dozens of YAML files is quick at first, but when a developer renames a button or a label, you have to update many flows for one small UI change. This does not scale.

The Page Object Model (POM) solves exactly this problem. POM is a way to organize flows: you collect the element names of each screen (each "page") in its own file, and flows use those names instead of raw ids. When the UI changes, you fix one place, and every flow that depends on it keeps working.

In Astra Scout, these files are JavaScript files. They store selectors in the output object (the place that holds variables shared across the steps of a flow).

The core idea ​

The principle behind POM is simple:

Separate what an element is from how your test interacts with it.

You achieve this by:

  1. Defining selectors in JavaScript files using the output object.
  2. Loading those definitions at runtime.
  3. Referencing variables (not raw ids) in your Flows.

As a result, flows describe what the user wants to do, not technical details.

The basic workflow ​

To apply POM:

  1. Store id, text, or regex patterns in .js files using output.
  2. Group selectors logically by screen, feature, or platform.
  3. Use runScript, or a shared loader flow, to load the selectors into the flow.
  4. Reference selectors using ${output...} in tapOn, inputText, assertVisible, and other commands.

The sections below go through each step.

1. Define your first page object ​

Start by creating a JavaScript file that represents a screen in your app. For example, a login screen.

javascript
// login.js
output.login = {
    email: 'email_text',
    password: 'password_text',
    loginBtn: 'loginButton',
    registerBtn: 'registerButton'
}

This file is the "dictionary" of the Login screen. The key says what the element is, and the value tells Astra Scout how to find it.

Use runScript to load the file into the flow. After that the flow has no raw IDs, and each line shows its intent clearly.

yaml
# login_flow.yaml
---
- runScript: login.js

- tapOn:
    id: ${output.login.email} 
- inputText: "simon@maestro.com"

- tapOn: 
    id: ${output.login.password} 
- inputText: ${PASSWORD}

- tapOn: 
    id: ${output.login.loginBtn}

2. Organize complex screens with nested objects ​

On complex screens, a flat list of selectors is hard to manage. You can nest objects to follow the structure of the UI. This suits reusable components such as cards, toolbars, or navigation menus. The following example describes a card:

javascript
// cards.js
output.cards = {
    cardTitle: 'card_title',

    virtualCards: {
        createBtn: 'btn_create_virtual',
        details: {
            number: 'card_number',
            expiry: 'card_expiry'
        }
    }
}

This structure matches the UI hierarchy: related elements sit together and names do not clash. In a flow, you follow the path as you would in a JavaScript object. The path itself shows where the element sits in the UI, so the flow is easier to read and update.

yaml
- runScript: cards.js

- tapOn: 
    id: ${output.cards.virtualCards.createBtn} 
- assertVisible: 
    id: ${output.cards.virtualCards.details.number}

POM benefits and trade-offs ​

POM makes a suite easier to maintain, but it has one drawback:

  • Fix in one place: If btn_create_virtual changes to fab_add, you update one file, not dozens of tests.

  • No name clashes: Nested objects prevent collisions between common names like title, submit, or button.

  • Regex support: Astra Scout reads selector strings as regex (patterns that describe text, see Selectors). You can store patterns for changing text, like:

    javascript
    output.welcomeBanner = 'Welcome back, .*'

    and reuse them across flows.

  • Readable failures: when a flow fails, a name such as loginBtn is easier to understand than a raw ID.

The drawback: flows are slightly less direct to read. Writing the text directly is easy to read but breaks easily when the UI changes:

- tapOn: Add to Basket

With POM it is a little less direct, but it lasts:

- tapOn: ${output.itemPage.addButton}

Choose POM when your suite is large and needs to last more than it needs to be readable line by line in YAML.

Keep selectors apart from the flows, for example in a dedicated elements folder.

For apps that run on both Android and iOS, divide the folders by platform. The flows stay the same for both platforms, while the selectors are specific to each platform.

android/
  elements/
    login.js
    home.js
    loadElements.yaml
  tests/
    login_flow.yaml

ios/
  elements/
    login.js
    home.js
    loadElements.yaml
  tests/
    login_flow.yaml

The loader strategy ​

As your project grows, calling runScript in every flow becomes repetitive and easy to get wrong. Use a loader flow instead. Its only job is to load all page objects once:

yaml
# elements/loadElements.yaml
appId: com.example.app
---
- runScript: cards.js
- runScript: login.js
- runScript: home.js
- runScript: nav.js

Call the loader as a subflow at the start of a flow. All selectors are loaded, so every flow starts the same way and no selector is forgotten.

yaml
---
- runFlow: elements/loadElements.yaml
- launchApp
# All selectors are now available via ${output...}

Handle cross-platform differences ​

Android and iOS often use different IDs for the same element. With POM you keep the names the same and only change which file is loaded.

yaml
- runFlow:
    when: 
        platform: Android 
    commands:
      - runScript: android/elements/login.js

- runFlow:
    when: 
        platform: iOS
    commands:
      - runScript: ios/elements/login.js

Each platform loads its own selectors, while the steps of the flow stay the same.

yaml
- tapOn:
    id: ${output.login.loginBtn}

Related pages: