WebdriverIO Page Object Model Pattern

WebdriverIO Page Object Model Pattern

As WebdriverIO test suites grow, tests that embed raw selectors and action logic directly become a maintenance burden. Change a CSS class, rename a button, or restructure a page — suddenly dozens of tests need updating. The Page Object Model (POM) solves this by creating a layer of abstraction between your tests and the UI.

What Is the Page Object Model?

The Page Object Model organizes your test code into two layers:

  1. Page objects — classes that model each page (or component) of your application. They contain selectors and methods that interact with that page.
  2. Tests — specs that use page objects to express intent in readable language.

Tests describe what should happen. Page objects describe how to make it happen.

// Without POM — raw selectors in tests
it('should login successfully', async () => {
  await browser.url('/login');
  await $('#email').setValue('user@example.com');
  await $('#password').setValue('secret123');
  await $('button[type="submit"]').click();
  expect(await browser.getUrl()).toContain('/dashboard');
});

// With POM — tests read like documentation
it('should login successfully', async () => {
  await loginPage.open();
  await loginPage.login('user@example.com', 'secret123');
  await expect(dashboardPage.isLoaded()).resolves.toBe(true);
});

Setting Up Page Objects

Create a dedicated directory for page objects:

test/
├── specs/
│   ├── auth/
│   │   └── login.e2e.js
│   └── shop/
│       └── checkout.e2e.js
└── pageobjects/
    ├── Page.js          ← base class
    ├── LoginPage.js
    ├── DashboardPage.js
    └── CheckoutPage.js

The Base Page Class

Start with a base class that handles common operations:

// test/pageobjects/Page.js
export class Page {
  /**
   * Navigate to this page.
   * @param {string} path - URL path relative to baseUrl
   */
  async open(path = '/') {
    await browser.url(path);
    return this;
  }

  /**
   * Wait for page to finish loading.
   */
  async waitForLoad(timeout = 10000) {
    await browser.waitUntil(
      async () => {
        const state = await browser.execute(() => document.readyState);
        return state === 'complete';
      },
      { timeout, timeoutMsg: 'Page did not finish loading' }
    );
    return this;
  }

  /**
   * Check if element exists in the DOM (not necessarily visible).
   */
  async exists(selector) {
    const elements = await $$(selector);
    return elements.length > 0;
  }

  /**
   * Scroll element into view before interacting.
   */
  async scrollTo(element) {
    await element.scrollIntoView();
    return element;
  }
}

A Login Page Object

// test/pageobjects/LoginPage.js
import { Page } from './Page.js';

export class LoginPage extends Page {
  // Selectors as getters — lazy evaluation, always fresh
  get emailInput() { return $('#email'); }
  get passwordInput() { return $('#password'); }
  get submitButton() { return $('button[type="submit"]'); }
  get errorMessage() { return $('[data-testid="error-message"]'); }
  get forgotPasswordLink() { return $('a[href="/forgot-password"]'); }
  get rememberMeCheckbox() { return $('[data-testid="remember-me"]'); }

  async open() {
    return super.open('/login');
  }

  async login(email, password) {
    await this.emailInput.setValue(email);
    await this.passwordInput.setValue(password);
    await this.submitButton.click();
  }

  async loginAndWait(email, password) {
    await this.login(email, password);
    // Wait for navigation away from login page
    await browser.waitUntil(
      async () => !(await browser.getUrl()).includes('/login'),
      { timeout: 5000, timeoutMsg: 'Login did not redirect' }
    );
  }

  async getErrorMessage() {
    await this.errorMessage.waitForDisplayed({ timeout: 3000 });
    return this.errorMessage.getText();
  }

  async isErrorDisplayed() {
    return this.errorMessage.isDisplayed();
  }
}

// Export a singleton instance
export const loginPage = new LoginPage();

A More Complex Page Object

// test/pageobjects/ProductListPage.js
import { Page } from './Page.js';

export class ProductListPage extends Page {
  get searchInput() { return $('[data-testid="search-input"]'); }
  get searchButton() { return $('[data-testid="search-button"]'); }
  get productCards() { return $$('[data-testid="product-card"]'); }
  get sortDropdown() { return $('[data-testid="sort-by"]'); }
  get filterPanel() { return $('[data-testid="filter-panel"]'); }
  get noResultsMessage() { return $('[data-testid="no-results"]'); }
  get loadMoreButton() { return $('[data-testid="load-more"]'); }

  // Dynamic selectors that depend on parameters
  categoryFilter(category) {
    return $(`[data-testid="filter-${category}"]`);
  }

  productCard(index) {
    return $(`[data-testid="product-card"]:nth-child(${index + 1})`);
  }

  async open() {
    return super.open('/products');
  }

  async search(query) {
    await this.searchInput.setValue(query);
    await this.searchButton.click();
    await this.waitForResults();
  }

  async waitForResults(timeout = 5000) {
    await browser.waitUntil(
      async () => {
        const cards = await this.productCards;
        return cards.length > 0 || await this.noResultsMessage.isDisplayed();
      },
      { timeout, timeoutMsg: 'Search results did not appear' }
    );
  }

  async getProductCount() {
    return (await this.productCards).length;
  }

  async getProductNames() {
    const cards = await this.productCards;
    return Promise.all(
      cards.map(card => card.$('[data-testid="product-name"]').getText())
    );
  }

  async sortBy(option) {
    await this.sortDropdown.selectByVisibleText(option);
    await this.waitForResults();
  }

  async applyFilter(category) {
    const filter = this.categoryFilter(category);
    if (!(await filter.isSelected())) {
      await filter.click();
    }
    await this.waitForResults();
  }

  async addToCart(productIndex = 0) {
    const card = await this.productCard(productIndex);
    await card.$('[data-testid="add-to-cart"]').click();
    
    // Wait for confirmation
    await browser.waitUntil(async () => {
      const count = await $('[data-testid="cart-count"]').getText();
      return parseInt(count) > 0;
    }, { timeout: 3000 });
  }
}

export const productListPage = new ProductListPage();

Writing Tests with Page Objects

Tests become readable and concise:

// test/specs/auth/login.e2e.js
import { loginPage } from '../../pageobjects/LoginPage.js';

describe('Login', () => {
  beforeEach(async () => {
    await loginPage.open();
  });

  it('should login with valid credentials', async () => {
    await loginPage.login('user@example.com', 'password123');
    
    await browser.waitUntil(
      async () => (await browser.getUrl()).includes('/dashboard'),
      { timeout: 5000 }
    );
  });

  it('should show error for wrong password', async () => {
    await loginPage.login('user@example.com', 'wrongpassword');
    
    const error = await loginPage.getErrorMessage();
    expect(error).toContain('Invalid credentials');
  });

  it('should show error for empty email', async () => {
    await loginPage.login('', 'password123');
    
    await expect(loginPage.errorMessage).toBeDisplayed();
  });

  it('should navigate to forgot password', async () => {
    await loginPage.forgotPasswordLink.click();
    expect(await browser.getUrl()).toContain('/forgot-password');
  });
});
// test/specs/shop/search.e2e.js
import { loginPage } from '../../pageobjects/LoginPage.js';
import { productListPage } from '../../pageobjects/ProductListPage.js';

describe('Product search', () => {
  before(async () => {
    await loginPage.open();
    await loginPage.loginAndWait('user@example.com', 'password123');
  });

  beforeEach(async () => {
    await productListPage.open();
  });

  it('should find products matching search query', async () => {
    await productListPage.search('laptop');
    
    const count = await productListPage.getProductCount();
    expect(count).toBeGreaterThan(0);
    
    const names = await productListPage.getProductNames();
    expect(names.some(name => name.toLowerCase().includes('laptop'))).toBe(true);
  });

  it('should show no results for invalid query', async () => {
    await productListPage.search('xyzzy-nonexistent-product-12345');
    
    await expect(productListPage.noResultsMessage).toBeDisplayed();
  });

  it('should filter by category', async () => {
    await productListPage.applyFilter('electronics');
    
    const count = await productListPage.getProductCount();
    expect(count).toBeGreaterThan(0);
  });
});

Component Page Objects

Not every page object needs to represent a full page. Model reusable UI components too:

// test/pageobjects/components/Modal.js
export class Modal {
  constructor(selector = '[role="dialog"]') {
    this.selector = selector;
  }

  get container() { return $(this.selector); }
  get title() { return $(this.selector + ' h2'); }
  get closeButton() { return $(this.selector + ' [data-testid="close"]'); }
  get confirmButton() { return $(this.selector + ' [data-testid="confirm"]'); }
  get cancelButton() { return $(this.selector + ' [data-testid="cancel"]'); }

  async waitForOpen(timeout = 5000) {
    await this.container.waitForDisplayed({ timeout });
  }

  async waitForClose(timeout = 5000) {
    await this.container.waitForDisplayed({ timeout, reverse: true });
  }

  async getTitle() {
    await this.waitForOpen();
    return this.title.getText();
  }

  async confirm() {
    await this.confirmButton.click();
    await this.waitForClose();
  }

  async cancel() {
    await this.cancelButton.click();
    await this.waitForClose();
  }

  async close() {
    await this.closeButton.click();
    await this.waitForClose();
  }
}

Use the component in page objects:

import { Modal } from './components/Modal.js';

export class ProductListPage extends Page {
  get deleteModal() {
    return new Modal('[data-testid="delete-confirm-modal"]');
  }

  async deleteProduct(index) {
    const card = await this.productCard(index);
    await card.$('[data-testid="delete-button"]').click();
    await this.deleteModal.waitForOpen();
    await this.deleteModal.confirm();
  }
}

Handling Dynamic Content

Page objects need to handle dynamic UIs gracefully:

export class DataTablePage extends Page {
  get table() { return $('[data-testid="data-table"]'); }
  get rows() { return $$('[data-testid="table-row"]'); }
  get loadingSpinner() { return $('[data-testid="loading"]'); }
  
  async waitForLoad(timeout = 10000) {
    // Wait for spinner to disappear
    await this.loadingSpinner.waitForDisplayed({ 
      timeout, 
      reverse: true,
      timeoutMsg: 'Table never finished loading' 
    });
  }

  async getRowCount() {
    await this.waitForLoad();
    return (await this.rows).length;
  }

  async getRowData(rowIndex) {
    await this.waitForLoad();
    const rows = await this.rows;
    const row = rows[rowIndex];
    
    const cells = await row.$$('td');
    return Promise.all(cells.map(cell => cell.getText()));
  }

  async clickRow(rowIndex) {
    const rows = await this.rows;
    await rows[rowIndex].click();
  }
}

Testing Tips

Keep page objects thin. They should contain selectors and actions — not assertions. Assertions belong in tests.

// WRONG — assertions in page objects
async login(email, password) {
  await this.emailInput.setValue(email);
  await this.passwordInput.setValue(password);
  await this.submitButton.click();
  expect(await browser.getUrl()).toContain('/dashboard'); // ← no
}

// CORRECT — return state, assert in tests
async login(email, password) {
  await this.emailInput.setValue(email);
  await this.passwordInput.setValue(password);
  await this.submitButton.click();
}

Use getters for selectors — they evaluate lazily, so WebdriverIO fetches the element fresh on each access rather than caching a stale reference.

Name methods from the user's perspectiveloginAs(email, password) not fillFormAndSubmit(email, password).

Don't over-abstract. If a selector only appears in one test, embedding it in the test is fine. Page objects add value when the same UI element appears across multiple tests.

Conclusion

The Page Object Model transforms a fragile test suite into a maintainable one. When UI changes happen — and they always do — you update the selector in one place instead of hunting through dozens of spec files.

The pattern pairs naturally with WebdriverIO's async API. Start with a base Page class, build page objects for each major page, and extract components for reusable UI elements. Keep selectors and actions in page objects, assertions in tests.

Combined with WebdriverIO's Mocha integration and CI/CD setup, the Page Object Model gives you the foundation for a test suite that can grow with your application.

Read more