Playwright Page Object Model: A Definitive Guide

  • Learning Hub
  • Playwright Page Object Model: A Definitive Guide

OVERVIEW

At some point, we have all worked on projects, which are poorly maintained, have tons of duplication, and are generally a pain to navigate around. In the past, this may have been overlooked and ignored. Still, as we as an industry continue to progress into faster-paced environments where daily, if not hourly, releases are the new normal, it's no longer possible to ignore these issues.

We don’t want to be in a position where something changes in the application, and suddenly we have hundreds of updates to make in the automation project. We want to be able to work closely with the developers, understand that a single locator is being updated, and make the same single update in our automation test suite.

The solution? A design pattern that has been around and still holds strong today is the Page Object Model (POM).

POM is a design pattern widely used in test automation to create more maintainable and scalable test suites. Playwright is a powerful open-source tool for automating web browsers that provides a high-level API for interacting with web pages and supports multiple programming languages.

Using POM with Playwright can bring many benefits to automation testing, including:

  • Better code organization
  • Improved test stability
  • Increased reusability
  • Easier collaboration

In this Playwright automation testing tutorial, we will learn to use Playwright Page Object Model to improve the quality and efficiency of test automation by providing a scalable and maintainable approach to test development.

...

Table of Contents

What is the Page Object Model (POM)?

It does what it says - Page Object Model is a design practice that allows us to model a collection of objects to a specific page type. Despite the name, the page isn’t always literal; it could be a component or section of the website. For example, we could have a HomePage, a LoginPage, and a FooterPage - each with locators and behaviors specific to that area of the website.

However generally the page object pattern is a design practice that is commonly adopted by testers when performing Selenium automation testing.This makes the test code more robust and easier to maintain overall, which makes it more programmer-friendly.

Page Object Model (POM) Sketch Note By Louise J Gibbs

By Louise J Gibbs

Consider a real-world example below where each red box could be translated to a page - the larger red box would be our ‘HomePage’. It may validate displayed elements and that the page has been rendered correctly. Then we dig deeper into the other two red boxes, our ‘SearchPage’ and ‘AccountPage’, where we interact with these components and their related pages.

SearchPage and AccountPage in lambdatest ecommerce

The main goal is to abstract all of the logic into nicely wrapped packages, so the person writing the tests can call simple methods in the actual tests. And if there is a change to the locator for the Login field, we only need to update the LoginPage and not the numerous tests that use Login

POM, as a concept, is also independent of your programming language. Once you understand the structure and approach, you can apply this across different projects regardless of the background or framework.

...

What are the benefits of using the Page Object Model?

The Page Object Model is a popular design pattern used in test automation to create maintainable and scalable test suites. It provides a way to organize code in a structured manner, improving test automation's overall efficiency and reliability. Here are some benefits of using the Page Object Model:

  • Maintainability - updates and changes are only made in a single location
  • Reusability - code can be reused throughout the test solution
  • Reliability - page code is decoupled from the tests, enabling us to minimize code change impacts
  • Readability - instead of plain code syntax, we can abstract this away and use more naturally named page objects

What does the Page Object Model structure look like?

Once you have installed and configured Playwright, this is how you may want to structure your project to follow a Playwright Page Object Model approach.

  • The ‘tests’ folder contains all of our spec files
  • The ‘pages’ folder contains the corresponding page files in line with our spec files
  • You will also notice we have a ‘base.page.js’; this is optional and can be used to store any global actions which are not isolated to a single component or page
  • Outside this folder structure, we have the usual package, config, and module files
  • We may want to add ‘utilities’, ‘config’ or additional ‘helper’ folders as the project grows, but this is all we need as a starting point
Playwright Page Object Model look like

Note: The completed project and files mentioned in this blog can be referenced here:

LambdaTest

Implementing Playwright Page Object Model in an existing project

It may be the case that you’ve joined a new organization or are working on a legacy automation solution and the Playwright Page Object Model was never implemented. This is not a problem - there may be additional hurdles as we have to rewrite code or restructure the project potentially, but it is achievable if planned correctly.

Project analysis and refactor

Let’s look at a project that hasn’t implemented Playwright Page Object Model and step through the needed changes

File: register-1.spec.js


// @ts-check
const { test, expect } = require('@playwright/test');


test('register new user', async ({ page }) => {
  await page.goto('?route=account/register')


  await page.locator('id=input-firstname').fill('steve')
  await page.locator('id=input-lastname').fill('james')
  await page.locator('id=input-email').fill('steve.james@gmail.com')
  await page.locator('id=input-telephone').fill('07864538876')
  await page.locator('id=input-password').fill('Abc123')
  await page.locator('id=input-confirm').fill('Abc123')
  await page.locator('text=I have read and agree to the Privacy Policy').click()
  await page.locator('text=Continue').click()
  await page.locator('text=Continue').isHidden()
  await expect(page.locator('.page-title')).toHaveText(' Your Account Has Been Created!')
});


test('register new user', async ({ page }) => {
  await page.goto('?route=account/register')
 
  await page.locator('id=input-firstname').fill('diane')
  await page.locator('id=input-lastname').fill('peters')
  await page.locator('id=input-email').fill('diane.peters@outlook.com')
  await page.locator('id=input-telephone').fill('0269638885')
  await page.locator('id=input-password').fill('Def£$%')
  await page.locator('id=input-confirm').fill('Def£$%')
  await page.locator('text=I have read and agree to the Privacy Policy').click()
  await page.locator('text=Continue').click()
  await page.locator('text=Continue').isHidden()
  await expect(page.locator('.page-title')).toHaveText(' Your Account Has Been Created!')
});

We have 2 tests, which are following the register journey, there are slight differences in the inputs which are being sent - as we would like to verify the user can complete the form with different data, as long as it's valid.

Let’s start by cleaning up the navigation. Instead of declaring page.goto in each test, we can add the following snippet to the top of the file, and the navigation will be executed before every test in that spec file:


test.beforeEach(async ({ page }) => {
  await page.goto('?route=account/register')
});

Next, we should start by making the page actions more maintainable. Let’s start with the first name field.


await page.locator('id=input-firstname').fill('steve')

We will need to create a page file to store our locators and actions. I've created one called register.page.js.


exports.RegisterPage = class RegisterPage {
  constructor(page) {
    this.page = page;
    this.firstNameField = page.locator('id=input-firstname');
  }
 
  async enterFirstName(firstName) {
    await this.firstNameField.fill(firstName)
  }
}

The first line is exporting the RegisterPage so that we can use it in our test file. Following that is a constructor where we will be storing our locators - we separate them like this for ease of maintenance, but you can also declare them directly in your methods. Lastly, we have the enterFirstName method, which simply fills in the first name field with a firstName string value defined as a parameter.

Having the firstName value as a parameter allows us to reuse this method in multiple tests while providing different values. This is the basic goal of the Playwright Page Object Model, to provide reusability and ease of maintenance - if the locator for the first name field changes, we only need to change one value in our page file.

Now if we return to our test file, we can implement this method that we have created:


// @ts-check
const { test, expect } = require('@playwright/test');
const { RegisterPage } = require('../pages/register.page');


test.beforeEach(async ({ page }) => {
  await page.goto('?route=account/register')
});


test('register new user', async ({ page }) => {
  const registerPage = new RegisterPage(page);
  await registerPage.enterFirstName('steve')
  await page.locator('id=input-email').fill('steve.james@gmail.com')
  await page.locator('id=input-telephone').fill('07864538876')
  await page.locator('id=input-password').fill('Abc123')
  await page.locator('id=input-confirm').fill('Abc123')
  await page.locator('text=I have read and agree to the Privacy Policy').click()
  await page.locator('text=Continue').click()
  await page.locator('text=Continue').isHidden()
  await expect(page.locator('.page-title')).toHaveText(' Your Account Has Been Created!')
});


test('register new user 2', async ({ page }) => {
  const registerPage = new RegisterPage(page);
  await registerPage.enterFirstName('diane')
  await page.locator('id=input-lastname').fill('peters')
  await page.locator('id=input-email').fill('diane.peters@gmail.com')
  await page.locator('id=input-telephone').fill('07874638885')
  await page.locator('id=input-password').fill('Def£$%')
  await page.locator('id=input-confirm').fill('Def£$%')
  await page.locator('text=I have read and agree to the Privacy Policy').click()
  await page.locator('text=Continue').click()
  await page.locator('text=Continue').isHidden()
  await expect(page.locator('.page-title')).toHaveText('Your Account Has Been Created!')
});

At the top of the file you will see a new line for ‘require’, where we are referencing the register.page file location.


const { RegisterPage } = require('../pages/register.page');

We can use this in our tests by defining registerPage in the following way:


const registerPage = new RegisterPage(page);

This allows us to type registerPage. - and have access to all methods on that page. For example:


await registerPage.enterFirstName('steve')
  await registerPage.enterFirstName('diane')

We can now use this reusable method throughout different tests and only replace the string value we send for that field. Now we will make these same changes for the other fields in the test. You should have something that looks like this:


 await registerPage.enterFirstName('steve')
  await registerPage.enterLastName('james')
  await registerPage.enterEmail('steve.james@gmail.com')
  await registerPage.enterTelephoneNumber('07864538876')
  await registerPage.enterPassword('Abc123')
  await registerPage.enterConfirmPassword('Abc123')

That is all of the text fields refactored - at first glance, you can already see how much cleaner and readable it is. We will copy this code for our second test and change the data we send.

Next is to add our click methods and Playwright assertions to our Page Object Model. In the register.page.js, we will make the following changes - adding the relevant locators to our constructor object and creating the reusable click methods with them.



this.privacyPolicyCheckbox = page.locator('id=text=I have read and agree to the Privacy Policy');
    this.continueButton = page.locator('text=Continue');


async clickPrivacyPolicy() {
    await this.privacyPolicyCheckbox.click()
  }


  async clickContinue() {
    await this.continueButton.click()
  }

In the test, we can now reference them easily like this:


await registerPage.clickPrivacyPolicy()
  await registerPage.clickContinue()

The last action we need to perform is the assertion. We can wrap both our ‘isHidden’ and ‘expect’ checks together like this:


 async assertAccountCreation(message) {
    await this.continueButton.isHidden()
    await expect(this.accountCreationMessage).toHaveText(message)
  }

Include the following at the top of your page file to use the expect method:


const { expect } = require('@playwright/test');

As they are linked behaviors, it makes sense to group them like this - it also reduces the number of lines in the actual test slightly, as we can now call it like this:


  await registerPage.assertAccountCreation('Your Account Has Been Created!')

The end result

And we are done! We’ve implemented a Playwright Page Object Model and used it successfully in our tests. The finished files should look something like this:

finished-files-should-look

Easy to understand what is going on, and if the structure of our registration form ever changes, it will be simple to make changes to our code. Not only that, but when we add tests in the future, much of the groundwork will already be done. It’ll just be a case of modifying our reusable methods.

Can we improve our Page Object Model further?

Less code is always better, and it is always more difficult to take code away than to add it. Let’s take another look at our test file and see where we can make potential improvements.

The first area that stood out to me is the end part of our test - after we have entered our data and we need to tick a check box, click continue, and assert the page we are on. These actions are the same for each test, so why don’t we move them out somewhere?


test.afterEach(async ({ page }) => {
  const registerPage = new RegisterPage(page);
  await registerPage.clickPrivacyPolicy()
  await registerPage.clickContinue()
  await registerPage.assertAccountCreation('Your Account Has Been Created!')
});

Playwright has an afterEach concept we can use, similar to the beforeEach method we added at the start of our test. This will execute after each test and allow us to remove that block of duplication from both tests.

The next fairly large refactor we can make is related to entering details - this change is open to debate; it reduces the number of lines maintained in your test files but reduces readability slightly. As you can see, we have grouped the fill actions into a single method, with all parameters added to our new ‘enterDetails’ method.



await registerPage.enterDetails('steve', 'james', 'steve.james@gmail.com', '07864538876', 'Abc123', 'Abc123')


  async enterDetails(firstName, lastName, email, telephoneNumber, password, confirmPassword) {
    await this.firstNameField.fill(firstName)
    await this.lastNameField.fill(lastName)
    await this.emailField.fill(email)
    await this.telephoneField.fill(telephoneNumber)
    await this.passwordField.fill(password)
    await this.confirmField.fill(confirmPassword)
  }

A quick hover over the code in our test explains what each parameter is if you don’t want to jump around files:

what-each-parameter-is

A similar approach could be taken to the code we were looking at earlier - does the person writing your tests need to know we are clicking a checkbox and clicking continue? Or do we just care that the account is created successfully? Again this is open to debate and is best discussed within your team on how you would like to structure your framework.


test.afterEach(async ({ page }) => {
  const registerPage = new RegisterPage(page);
  await registerPage.assertAccountCreation('Your Account Has Been Created!')
});


  async assertAccountCreation(message) {
    await this.privacyPolicyCheckbox.click()
    await this.continueButton.click()
    await this.continueButton.isHidden()
    await expect(this.accountCreationMessage).toHaveText(message)
  }

What is the result of our implementing the POM?

  • Reduced lines of code to maintain.
  • Abstracted logic away from the user creating the tests.
  • Made it easier to create new tests quickly.
  • Opened up opportunities to refactor further if needed.

Implementing Playwright Page Object Model in a new project

That was a fairly simple example of how we can refactor and implement the Playwright Page Object Model in an existing project - now let’s implement it from scratch and cover a journey covering multiple pages. This will allow us to explore the additional benefits that Playwright Page Object Model provides, as we can break down areas of the website into smaller chunks.

Project structure

The journey we will cover involves logging in, searching for an item, and adding the first item on the 2nd page - let’s start by planning the pages we need to create.

  • Home page - The home screen page we land on when we first visit the site.
  • Login page - The login screen where we can manage login details, forgot password, and other account-related options.
  • Search page - This could be managed on the ‘Home page’. It depends on how much you want to abstract away your pages. This will manage the search component, including the category drop-down and search button.
  • Software page - As the visuals and layout of some categories differ, we will add a page for just the Software view, including our page navigation and product selection.
  • Cart page - This is an additional page for managing the cart and checkout widget. We will manage the assertion that the item has been added here.

This is what your project should look like with all of the files added. We have also added an add-to-cart.spec.js file for the test we are about to write:

Add to cart.spec

The first page we will implement is login - the page object should look like this:

File: login.page.js


const { expect } = require('@playwright/test');


exports.LoginPage = class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailField = page.locator('id=input-email');
    this.passwordField = page.locator('id=input-password');
    this.loginButton = page.locator('input:has-text("Login")')
  }
 
  async enterEmail(email) {
    await this.emailField.fill(email)
  }


  async enterPassword(password) {
    await this.passwordField.fill(password)
  }


  async clickLogin() {
    await this.loginButton.click()
  }
}

The behaviors are fairly simple, enter our details and click the login button. We have added the email and password fields as parameters to be reused in future tests.

In the add-to-cart.spec.js file, our login methods will be implemented like this - we are reusing the user details from the account we registered earlier. As the test becomes more detailed, we can consider condensing this login behavior to a single method, but for now, this works well.


// @ts-check
const { test } = require('@playwright/test');
const { LoginPage } = require('../pages/login.page');


test.beforeEach(async ({ page }) => {
    await page.goto('?route=account/login')
  });


test('add to cart - software product', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.enterEmail("steve.james@gmail.com")
    await loginPage.enterPassword("Abc123")
    await loginPage.clickLogin()
});


After logging in, we land on the account screen - the search bar is visible at all times so that we won’t put these actions in an Account Page. We will instead add it to the Search Page we created. We will select ‘Software’ from the dropdown, search for the keyword ‘iPod’ and click search.

File: search.page.js


const { expect } = require('@playwright/test');


exports.SearchPage = class SearchPage {
  constructor(page) {
    this.page = page;
    this.categoryAllCategories = page.locator('#entry_217822 button:has-text("All Categories")');
    this.categorySoftware = page.locator('#entry_217822 >> text=Software');
    this.searchField = page.locator('#entry_217822 [placeholder="Search For Products"]');
    this.searchButton = page.locator('text=Search');
  }
 
  async selectCategorySoftware() {
    await this.categoryAllCategories.click()
    await this.categorySoftware.click()
  }


  async searchForItem(item) {
    await this.searchField.fill(item)
  }


  async clickSearchButton() {
    await this.searchButton.click()
  }
}

File: add-to-cart-.spec.js


    await searchPage.selectCategorySoftware()
    await searchPage.searchForItem('iPod')
    await searchPage.clickSearchButton()

If we run our test up until this point, you will see we are on the following page and logged in:

LambdaTest playground pom

Now, to wrap up the test, we want to click through to the 2nd page, add the first item to our cart and assert that it has been added successfully. We will implement this logic in the software.page.js and cart.page.js, then add it to our test.

The file below is the software.page.js. We are clicking the 2nd page navigation and then using the ‘first()’ method to hover over the first carousel item and add it to our cart.

File: software.page.js


const { expect } = require('@playwright/test');


exports.SoftwarePage = class SoftwarePage {
  constructor(page) {
    this.page = page;
    this.secondPage = page.locator('a:has-text("2")');
    this.addToCart = page.locator('button:has-text("Add to Cart")');
    this.carouselItem = page.locator('.carousel-item');
  }
 
  async clickSecondPage() {
    await this.secondPage.click()
  }


  async addFirstItemToCart() {
    await this.carouselItem.first().hover()
    await this.addToCart.first().click()
  }
}

File: add-to-cart.spec.js


 await softwarePage.clickSecondPage()
    await softwarePage.addFirstItemToCart()

Next is the cart.page.js, where we simply define the cart popup locator.

File: cart.page.js


const { expect } = require('@playwright/test');


exports.CartPage = class CartPage {
  constructor(page) {
    this.page = page;
    this.cartPopup = page.locator('div[role="alert"]');
  }
}

The reason for this is that we are going to follow a generally accepted best practice and define our assertion in the test file. It’s a simple assertion that checks the message containing the success text and the name of the item we added to our cart.

File: add-to-cart.spec.js


File: add-to-cart.spec.js
await expect(cartPage.cartPopup).toContainText('Success: You have added iPod Touch to your shopping cart!')

Let’s take a look at the finished spec file - we can see the multiple pages we have defined for each area of the website and how easy it would be to change, add or remove any step. It allows us to create new tests using our existing page objects easily.

File: add-to-cart.spec.js


// @ts-check
const { test , expect } = require('@playwright/test');
const { LoginPage } = require('../pages/login.page');
const { SearchPage } = require('../pages/search.page');
const { SoftwarePage } = require('../pages/software.page');
const { CartPage } = require('../pages/cart.page');


test.beforeEach(async ({ page }) => {
await page.goto('?route=account/login')
});


test('add to cart - software product', async ({ page }) => {
const loginPage = new LoginPage(page);
const searchPage = new SearchPage(page);
const softwarePage = new SoftwarePage(page);
const cartPage = new CartPage(page);


await loginPage.enterEmail("steve.james@gmail.com")
await loginPage.enterPassword("Abc123")
await loginPage.clickLogin()


await searchPage.selectCategorySoftware()
await searchPage.searchForItem('iPod')
await searchPage.clickSearchButton()


await softwarePage.clickSecondPage()
await softwarePage.addFirstItemToCart()
await expect(cartPage.cartPopup).toContainText('Success: You have added iPod Touch to your shopping cart!')
});
')

Test execution

Most of what we’ve done so far is structuring the project and writing code. Now let’s get into some test execution to see it in action. We will use lambdatest-setup.js and playwright.config.js configurations to enable cross-platform test execution on the LambdaTest cloud platform.

With LambdaTest, you can minimize the time needed to execute your Playwright tests significantly. This is achieved by utilizing an online browser farm encompassing over 50 browser versions such as Chrome, Chromium, Microsoft Edge, Mozilla Firefox, and Webkit.

You can also subscribe to the LambdaTest YouTube Channel and stay updated with the latest tutorial around Playwright browser testing, Selenium Testing, Appium, and more.

For this test execution, we’ve selected ‘MacOS’ as the platform, with Chrome and Webkit as the browsers. We will run the test suite at least two times after building it to ensure test stability and review flakiness.

In this case, the tests were stable in both instances, and the execution time was around 29~33 seconds. This was running six tests with parallelism for four across both platforms.

Playwright parallelism for four across both platforms

Looking at the LambdaTest Dashboard, we can see our test results in more detail. In our case, the test duration is fairly flat across the board, so there are no concerns about slowness or poorly executing test cases. And as we mentioned earlier, the test pass rate is uniform across the board, so we are confident in this test suite and the quality of our product.

Playwright build POM project pagePlaywright certi CTA

LambdaTest's Playwright 101 certification is intended for developers seeking to exhibit their proficiency in utilizing Playwright to conduct end-to-end testing of contemporary web applications. This certification is the perfect means of highlighting your capabilities as a Playwright automation tester.

Conclusion

Hopefully that gives you some insight into how the Playwright Page Object Model can be implemented effectively and the benefits it can bring to your project. As we have mentioned along the way, the key factors for the Playwright Page Object Model are maintainability, reusability, reliability, and readability - an approach for test architecture that can be used for any project regardless of the language or framework in place.

Frequently Asked Questions (FAQs)

What is page object model in playwright?

In Playwright, Page Object Model (POM) is used to create a class representing a tested application page. This class is called a Page Object. It contains all the methods and properties required to interact with the elements of the page under test.

What is the difference between page object Model POM and Page Factory?

Page Object Model (POM) and Page Factory are design patterns used in test automation to create a more structured and maintainable code for automated tests. The main difference between the two is that Page Object Model (POM) is a design pattern that defines a class for each page in the application that contains all the necessary methods and properties to interact with the page, while Page Factory is an implementation of the POM pattern that uses annotations in the test script to initialize the web elements of the page object.

Try LambdaTest Now !!

Get 100 minutes of automation test minutes FREE!!

Next-Gen App & Browser Testing Cloud

Did you find this page helpful?

Helpful

NotHelpful