best practices - 19 min Read

App testing best practices with Appetize: Page Object Models (POM)

App testing best practices with Appetize: Page Object Models (POM)

Appetize makes it easy to run end-to-end tests on Android and iOS mobile apps – right in your browser. To help identify and fix issues even faster, we’re sharing our testing expertise.

In this blog post, we’re covering testing development best practices by showing you how to implement Page Object Models (POM) with Appetize AppRecorder and Playwright. To learn more about how Appetize AppRecorder and Playwright work together, check out the Appetize docs.

What is POM?

Page Object Models (POMs) describe and encapsulate portions of an application using classes. They provide a consistent and readable way for test scripts to interact with an app. As an application grows and test scripts become more complex, POM offers a scalable and structured solution for test development.

In this section, you’ll learn how to write POM classes to enhance the testing development experience.

But…why POM?

To demonstrate the issues a POM pattern solves, we’ll be doing an exercise developing tests with and without POM classes for Wikipedia. To simplify the example a little, we’ll be focusing on Wikipedia’s iOS application.

The goals for the tests are:

  1. Check that searching from the “Home” view works as expected

  2. Navigate to the “Places” and “Saved” tab views from the “Home” view

  3. Check the empty state for reading lists and all articles in the “Saved” tab view

Writing tests without POM

Create a file called wikipedia-app-no-pom.spec.ts. The non-POM tests will be stored here.

Isolating tests

First, make sure that the tests are independent and isolated. Call the reinstallApp method on the afterEach hook, cleaning the state of the application at the end of each test.

// Reinstall app after each test to reset data
test.afterEach(async ({ session, config }) => {
    await session.reinstallApp();
});

Test the “Places” tab navigation

Wait for the splash screen to disappear. After that, tap on the “Places” tab button to navigate through the application and check that the “Places” title is displayed.

test('Check the home view tab "places" works properly', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })
    
    // Tap on places
    await session.tap({ element: { attributes: { accessibilityLabel: 'Places', class: 'UITabBarButton' } } })
    
    // Check the title for places is displayed
    await expect(session).toHaveElement({ attributes: { accessibilityLabel: 'Places', class: 'UILabel' } })
})

Test the “Saved” tab navigation

Wait for the splash screen to disappear. After that, tap on the “Saved” tab button to navigate through the application and check that the “Saved” title is displayed.

test('Check the home view tab saved works properly', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })

    // Tap on saved
    await session.tap({ element: { attributes: { accessibilityLabel: 'Saved', class: 'UITabBarButton' } } })
    
    // Check the title for saved is displayed
    await expect(session).toHaveElement({ attributes: { accessibilityLabel: 'Saved', class: 'UILabel' } })
})

Test that the empty state in the “Saved” tab view works

Wait for the splash screen to disappear. After that, navigate to the “Saved” view and validate the “empty messages is displayed” for the tabs “All articles” and “Reading lists.”

test('Check the empty saved pages view is displayed', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })
    
    // Tap on saved
    await session.tap({ element: { attributes: { accessibilityLabel: 'Saved', class: 'UITabBarButton' } } })
    
    // Check the title for saved is displayed
    await expect(session).toHaveElement({ attributes: { accessibilityLabel: 'Saved', class: 'UILabel' } })
    
    //No saved pages yet in All articles
    await expect(session).toHaveElement({ attributes: { text: 'No pages saved yet' } })
    
    // Tap reading lists tab
    await session.tap({ element: { attributes: { text: 'Reading lists' } } })
    
    // Make sure there are no reading lists
    await expect(session).toHaveElement({ attributes: { text: 'Organize saved articles with reading lists' } })
})

Test the search functionality from the explore tab view

Wait for the main view to be visible. After that, tap on the search bar, type “VueJS” and ensure that you get the actual Vue.js entry in the results.

test('Search from the explore tab', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })

    // Focus the search bar
    await session.tap({ element: { attributes: { text: 'Search Wikipedia' } } })
    
    // Type on the searchbar
    await session.type('VueJS');
    
    //Check wiki result is there
    await expect(session).toHaveElement({ attributes: { text: 'Vue.js' } })
})

Test file without using the POM pattern

import { test, expect } from '@appetize/playwright';

// Reinstall app after each test to reset data
test.afterEach(async ({ session, config }) => {
    await session.reinstallApp();
});

test('Check the home view tab places works properly', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })

    // Tap on places
    await session.tap({ element: { attributes: { accessibilityLabel: 'Places', class: 'UITabBarButton' } } })
    
    // Check the title for places is displayed
    await expect(session).toHaveElement({ attributes: { accessibilityLabel: 'Places', class: 'UILabel' } })
})

test('Check the home view tab saved works properly', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })

    // Tap on saved
    await session.tap({ element: { attributes: { accessibilityLabel: 'Saved', class: 'UITabBarButton' } } })
    
    // Check the title for saved is displayed
    await expect(session).toHaveElement({ attributes: { accessibilityLabel: 'Saved', class: 'UILabel' } })
})

test('Check the empty saved pages view is displayed', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })

    // Tap on saved
    await session.tap({ element: { attributes: { accessibilityLabel: 'Saved', class: 'UITabBarButton' } } })
    
    // Check the title for saved is displayed
    await expect(session).toHaveElement({ attributes: { accessibilityLabel: 'Saved', class: 'UILabel' } })
    
    //No saved pages yet
    await expect(session).toHaveElement({ attributes: { text: 'No pages saved yet' } })
    
    // Tap reading lists tab
    await session.tap({ element: { attributes: { text: 'Reading lists' } } })
    
    // Make sure there are no reading lists
    await expect(session).toHaveElement({ attributes: { text: 'Organize saved articles with reading lists' } })
})

test('Search from the explore tab', async ({ session }) => {
    // Check the settings element from the main view are displayed since we are waiting for the splashscreen to finish
    await session.findElement({ attributes: { accessibilityLabel: 'Settings' } })

    // Focus the search bar
    await session.tap({ element: { attributes: { text: 'Search Wikipedia' } } })
    
    // Type on the searchbar
    await session.type('VueJS');
    
    //Check wiki result is there
    await expect(session).toHaveElement({ attributes: { text: 'Vue.js' } })
})

Looking at the results of the non-POM tests, multiple issues are visible:

  1. The test scenarios are difficult to read

  2. As the tests grow, more boilerplate code will be required

  3. The tests don’t scale well. Major changes in the application would require a painful refactoring effort.

  4. Mistakes are more likely while writing the logic of an already developed interaction (e.g. navigating through the application, there could be a typo in the element attributes).

Now that we’ve identified non-POM test issues, let’s compare them with the same tests using the POM pattern.

POM as the solution

To begin refactoring efforts using the POM pattern, create a class that will simplify the actions and verifications with UI elements.

InspectableElement class

The InspectableElement class will be a wrapper that contains the element attributes and a reference to the session. This provides a consistent way of storing elements that page models can interact with.

This wrapper class is just one example of what you can build on top of the JavaScript SDK. You can always extend or completely rewrite all the logic to fit your testing needs.

import { IOSElementAttributes, PlaywrightSession, expect } from "@appetize/playwright";

/**
 * Represents an element that can be inspected and interacted with/
 */
export default class InspectableElement {
    readonly attributes: IOSElementAttributes;
    readonly session: PlaywrightSession;

    /**
     * Initializes a new instance of the InspectableElement class.
     * @param attributes - The attributes describing the element.
     * @param session - The Playwright session used to interact with the element.
     */
    constructor(attributes: IOSElementAttributes, session: PlaywrightSession) {
        this.attributes = attributes;
        this.session = session;
    }

    /**
     * Finds the element on the page using the provided attributes.
     */
    async find() {
        await this.session.findElement({ attributes: this.attributes });
    }

    /**
     * Taps on the element using the provided attributes.
     */
    async tap() {
        await this.session.tap({ element: { attributes: this.attributes } });
    }

    /**
     * Asserts that the element is displayed on the page using the provided attributes.
     */
    async expectDisplayed() {
        await expect(this.session).toHaveElement({ attributes: this.attributes });
    }
}

MainPage POM class

This POM class represents the main view of the iOS Wikipedia application. It allows you to navigate to the main sections of the application, such as Saved, Places, and Settings.

import { PlaywrightSession } from "@appetize/playwright";
import InspectableElement from "./InspectableElement";

/**
 * Represents the Main Page of the Wikipedia iOS application and provides methods to interact with various UI elements.
 * This class follows the Page Object Model (POM) design pattern to encapsulate page interactions.
 */
export default class MainPage {
    private readonly session: PlaywrightSession;
    readonly searchBar: InspectableElement;
    readonly placesTabButton: InspectableElement;
    readonly savedTabButton: InspectableElement;
    readonly settingsButton: InspectableElement;

    /**
     * Initializes a new instance of the MainPage class.
     * @param session - The Playwright session used to interact with the Wikipedia iOS app.
     */
    constructor(session: PlaywrightSession) {
        this.session = session;
        this.searchBar = new InspectableElement({ text: 'Search Wikipedia' }, session)
        this.placesTabButton = new InspectableElement({ accessibilityLabel: 'Places', class: 'UITabBarButton' }, session)
        this.savedTabButton = new InspectableElement({ accessibilityLabel: 'Saved', class: 'UITabBarButton' }, session)
        this.settingsButton = new InspectableElement({ accessibilityLabel: 'Settings' }, session)
    }

    /**
     * Shows the saved view by tapping on the saved tab view.
     */
    async openSaved() {
        await this.savedTabButton.tap()
    }

    /**
     * Shows the places view by tapping on the places tab view.
     */
    async openPlaces() {
        await this.placesTabButton.tap()
    }

    /**
     * Performs a search by tapping on the search bar, typing the provided text, and executing the search.
     * @param text - The text to be typed into the search bar.
     */
    async search(text: string) {
        await this.searchBar.tap()
        await this.session.type(text);
    }

    /**
     * Assert we are in the home view (initial view controller container) of the application.
     * We will check two of the tabs to ensure we are on the home view.
     */
    async isDisplayed() {
        // Assert settings tab button is displayed
        await this.settingsButton.find()
    }
}

SavedPage POM class

This POM class represents the “Saved” tab view of the iOS Wikipedia app. It allows you to switch between the “Saved articles” and “Reading list” tab, as well as interact with the elements that belong to this view.

import { PlaywrightSession } from "@appetize/playwright";
import InspectableElement from "./InspectableElement";

/**
 * Represents the Saved Page of the Wikipedia iOS application and provides methods to interact with various UI elements.
 * This class follows the Page Object Model (POM) design pattern to encapsulate page interactions.
 */
export default class SavedPage {
    private readonly session: PlaywrightSession;
    readonly noSavedPagesLabel: InspectableElement;
    readonly readingListsTab: InspectableElement;
    readonly emptyReadingListLabel: InspectableElement;
    readonly title: InspectableElement;

    /**
     * Initializes a new instance of the SavedPage class.
     * @param session - The Playwright session used to interact with the Wikipedia iOS app.
     */
    constructor(session: PlaywrightSession) {
        this.session = session;
        this.title = new InspectableElement({ accessibilityLabel: 'Saved', class: 'UILabel' }, session)
        this.noSavedPagesLabel = new InspectableElement({ text: 'No saved pages yet' }, session)
        this.readingListsTab = new InspectableElement({ text: 'Reading lists', class: 'UIButtonLabel' }, session)
        this.emptyReadingListLabel = new InspectableElement({ text: 'Organize saved articles with reading lists' }, session)
    }

    /**
     * Open the reading list tab view by tapping the button
     */
    async openReadingList() {
        await this.readingListsTab.tap()
    }

    /**
     * Assert the title is displayed for the saved view.
     */
    async isDisplayed() {
        await this.title.expectDisplayed()
    }
}

PlacesPage POM class

This POM class represents the “Places” tab view of the iOS Wikipedia app. It allows you to validate if the “Places” view is displayed.

import { PlaywrightSession } from "@appetize/playwright";
import InspectableElement from "./InspectableElement";

/**
 * Represents the Places Page of the Wikipedia iOS application and provides methods to interact with various UI elements.
 * This class follows the Page Object Model (POM) design pattern to encapsulate page interactions.
 */
export default class PlacesPage {
    private readonly session: PlaywrightSession;
    readonly title: InspectableElement;

    /**
     * Initializes a new instance of the PlacesPage class.
     * @param session - The Playwright session used to interact with the Wikipedia iOS app.
     */
    constructor(session: PlaywrightSession) {
        this.session = session;
        this.title = new InspectableElement({ accessibilityLabel: 'Places', class: 'UILabel' }, session)
    }

    /**
     * Assert the title is displayed for the Places view.
     */
    async isDisplayed() {
        await this.title.expectDisplayed()
    }
}

Refactor the test cases

Create a file called wikipedia-app-pom.spec.ts. Inside it, copy the contents from the wikipedia-app-no-pom.spec.ts and replace the logic of the actions with the methods and elements that the POM classes provide.

import { test, expect } from '@appetize/playwright';
import MainPage from '../pom/HomePage';
import SavedPage from '../pom/SavedPage';
import PlacesPage from '../pom/PlacesPage';

// Reinstall app after each test to reset data
test.afterEach(async ({ session }) => {
    await session.reinstallApp();
});

test('Check the home view tab places works properly', async ({ session }) => {
    const homeView = new MainPage(session)
    await homeView.isDisplayed()
    await homeView.openPlaces()
    
    // Check places page is displayed
    await (new PlacesPage(session)).isDisplayed()
})

test('Check the home view tab saved works properly', async ({ session }) => {
    const homeView = new MainPage(session)
    await homeView.isDisplayed()
    await homeView.openSaved()
    
    // Check the saved view is displayed
    await (new SavedPage(session)).isDisplayed()
})

test('Check the empty saved pages view is displayed', async ({ session }) => {
    const homeView = new MainPage(session)
    await homeView.isDisplayed()
    await homeView.openSaved()

    const savedPage = new SavedPage(session)
    
    // Assert the saved view is shown
    await savedPage.isDisplayed()
    await savedPage.noSavedPagesLabel.expectDisplayed()
    
    // Open reading list tab and validate the empty text is shown
    await savedPage.openReadingList()
    await savedPage.emptyReadingListLabel.expectDisplayed()
})

test('Search from the explore tab', async ({ session }) => {
    const homeView = new MainPage(session)
    await homeView.isDisplayed()
    
    // Search on the home view the text VueJS
    await homeView.search('VueJS')
    
    //Check wiki result is there
    await expect(session).toHaveElement({ attributes: { text: 'Vue.js' } })
})

There you have it! The issues that came up when running the non-POM pattern test scripts have been fixed using the POM pattern:

  1. Readability of the test scripts improved using human-readable methods from the POM classes for actions like typing or tapping

  2. Reduced amount of boilerplate code: By encapsulating the logic of the views inside the POM classes, we can avoid rewriting complex flows multiple times, and instead use one of the methods exposed

  3. Scalability of the test scrips improved with the robust structure provided by the POM classes

  4. Each screen has a unique source of truth on how to interact with it, mitigating the risk of mistakes, especially when interactions are reused

Run the tests

Run the tests from the wikipedia-app-pom.spec.ts and see the results! To execute the tests using the demo app public key (which won’t support parallel tests), run it instead from the command line using the following:

npx playwright test tests/wikipedia-app-pom.spec.ts --headed --workers 1

Better testing with Appetize

AppRecorder is the next generation of testing, making it easy to unify your testing across web and mobile. Whether you’re working on your own project or with a big team, Appetize makes it easy to get started with minimal implementation.

Simplify your app testing

Words by

Augusto Alonso

Software Engineer

Our Product

Try Online Demo

share this article