Page Object Model is JavaScript classes applied to browser automation: a LoginPage class has a constructor that stores locators, methods that wrap interactions, and inherits common navigation from a BasePage via extends. The most common mistake when learning this pattern is forgetting super() as the first line of a child constructor, which prevents the parent's locators from being set up. This guide covers constructors, this, extends, super, and static utility methods, building to a complete page object example.

What Is a Class?

A class is a blueprint for creating objects. Every LoginPage you create from the class gets the same methods but operates independently.

class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton = page.getByTestId('submit-btn');
  }

  async navigate() {
    await this.page.goto('/login');
  }

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

Then you use it like this:

const loginPage = new LoginPage(page);
await loginPage.login('user@test.com', 'ValidPass1');

The constructor

The constructor runs when you create a new instance with new. It's where you initialize properties.

class ProductPage {
  constructor(page) {
    // 'this' refers to the instance being created
    this.page = page;
    this.productTitle = page.getByTestId('product-title');
    this.addToCartButton = page.getByTestId('add-to-cart');
    this.price = page.getByTestId('product-price');
    this.baseURL = '/products';
  }
}

// When you call new ProductPage(page):
// - constructor runs immediately
// - 'this.page' is set to the page argument
// - locators are stored on the instance
const productPage = new ProductPage(page);

this in Classes

this refers to the current instance. It's how methods access the instance's data.

class DashboardPage {
  constructor(page) {
    this.page = page;
    this.welcomeMessage = page.getByTestId('welcome');
    this.userName = page.getByTestId('user-name');
    this.logoutButton = page.getByTestId('logout');
  }

  async getUserName() {
    // 'this.userName' is the locator stored in the constructor
    return await this.userName.textContent();
  }

  async logout() {
    // 'this.logoutButton' is the button locator
    await this.logoutButton.click();
  }

  async verifyUser(expectedName) {
    // Methods can call other methods on 'this'
    const name = await this.getUserName();
    if (name !== expectedName) {
      throw new Error(`Expected ${expectedName}, got ${name}`);
    }
  }
}

Methods

Methods are functions defined on a class. They can be synchronous or async.

class SearchPage {
  constructor(page) {
    this.page = page;
    this.searchInput = page.getByTestId('search-input');
    this.searchButton = page.getByTestId('search-button');
    this.results = page.getByTestId('search-result');
    this.noResultsMessage = page.getByTestId('no-results');
  }

  // Simple async method
  async search(query) {
    await this.searchInput.fill(query);
    await this.searchButton.click();
  }

  // Method that returns a value
  async getResultCount() {
    return await this.results.count();
  }

  // Method with multiple steps
  async searchAndVerify(query, expectedCount) {
    await this.search(query);
    await this.page.waitForLoadState('networkidle');
    const count = await this.getResultCount();
    return count === expectedCount;
  }
}

Inheritance with extends

Inheritance lets one class build on another. In POM, you often have a BasePage that all pages extend.

// Base class with common behavior
class BasePage {
  constructor(page) {
    this.page = page;
    this.header = page.getByTestId('header');
    this.footer = page.getByTestId('footer');
  }

  async navigate(path) {
    await this.page.goto(path);
  }

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

  async takeScreenshot(name) {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }
}

// Child class inherits everything from BasePage
class LoginPage extends BasePage {
  constructor(page) {
    super(page);  // Must call super() first — runs BasePage's constructor
    this.emailInput = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton = page.getByTestId('submit-btn');
    this.errorMessage = page.getByTestId('error-message');
  }

  async login(email, password) {
    await this.navigate('/login');  // Inherited from BasePage
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorText() {
    return await this.errorMessage.textContent();
  }
}

Now LoginPage has both its own methods AND everything from BasePage:

const loginPage = new LoginPage(page);
await loginPage.login('user@test.com', 'pass');     // LoginPage method
await loginPage.takeScreenshot('after-login');       // BasePage method
const title = await loginPage.getTitle();            // BasePage method

super — Calling the Parent

super() calls the parent class's constructor. Required as the first line in a child constructor.

class AdminPage extends BasePage {
  constructor(page) {
    super(page);  // Runs BasePage constructor: sets this.page, this.header, this.footer
    // Now add AdminPage-specific things
    this.userTable = page.getByTestId('users-table');
    this.addUserButton = page.getByTestId('add-user');
  }
}

You can also call parent methods with super.methodName():

class CheckoutPage extends BasePage {
  async navigate() {
    // Call the parent navigate, then do extra stuff
    await super.navigate('/checkout');
    await this.page.waitForSelector('[data-testid="checkout-form"]');
  }
}

Static Methods

Static methods belong to the class itself, not instances. Useful for utility functions.

class TestData {
  static generateEmail() {
    return `user_${Date.now()}@test.com`;
  }

  static generateUser() {
    return {
      email: TestData.generateEmail(),
      password: 'ValidPass1',
      name: 'Test User',
    };
  }

  static generateProduct(overrides = {}) {
    return {
      name: 'Test Product',
      price: 99.99,
      category: 'electronics',
      ...overrides,
    };
  }
}

// Use without 'new' — call directly on the class
const email = TestData.generateEmail();
const user = TestData.generateUser();
const product = TestData.generateProduct({ price: 49.99 });

Getters

Getters look like properties but run a function when accessed:

class CartPage extends BasePage {
  constructor(page) {
    super(page);
    this.items = page.getByTestId('cart-item');
    this.totalElement = page.getByTestId('cart-total');
  }

  get url() {
    return '/cart';  // Computed property
  }

  async navigate() {
    await super.navigate(this.url);  // Uses the getter
  }

  async getItemCount() {
    return await this.items.count();
  }
}

const cartPage = new CartPage(page);
console.log(cartPage.url);  // '/cart' — no () needed

A Complete Page Object Example

Putting it all together — a realistic page object:

class ProductListPage extends BasePage {
  constructor(page) {
    super(page);
    this.productCards = page.getByTestId('product-card');
    this.filterDropdown = page.getByTestId('category-filter');
    this.sortDropdown = page.getByTestId('sort-select');
    this.searchInput = page.getByTestId('product-search');
    this.loadingSpinner = page.getByTestId('loading');
  }

  async navigate() {
    await super.navigate('/products');
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }

  async filterByCategory(category) {
    await this.filterDropdown.selectOption(category);
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }

  async sortBy(sortOption) {
    await this.sortDropdown.selectOption(sortOption);
  }

  async search(query) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }

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

  async getProductNames() {
    return await this.productCards.getByTestId('product-name').allTextContents();
  }

  async clickProduct(index) {
    await this.productCards.nth(index).click();
  }
}

// In tests
test('filter products by category', async ({ page }) => {
  const products = new ProductListPage(page);
  await products.navigate();
  await products.filterByCategory('electronics');
  
  const count = await products.getProductCount();
  expect(count).toBeGreaterThan(0);
});

Class vs Object Literal

For one-off things, an object literal is simpler. For something you reuse across tests, a class makes sense.

// Object literal — fine for constants
const TEST_USERS = {
  admin: { email: 'admin@test.com', password: 'AdminPass1' },
  member: { email: 'member@test.com', password: 'MemberPass1' },
};

// Class — good for page objects used in many tests
class LoginPage extends BasePage {
  // ...
}

Summary

  • constructor(page) — runs on new, store locators here
  • this — refers to the instance; how methods access the object's data
  • extends — inherit from another class (e.g., BasePage)
  • super() — call parent constructor; required first line in child constructor
  • static — class-level methods, no instance needed
  • Methods can be async — return promises, use await inside

Classes in Playwright Page Objects aren't magic. They're just JavaScript's way of bundling related locators and methods into one reusable unit. Once you understand constructor, this, and extends, the rest follows naturally.

→ See also: Page Object Model in Playwright: From Messy to Maintainable | TypeScript Interfaces and Types for Page Object Model | JavaScript for QA Engineers: The Minimum You Need to Start Automating