August 16, 2026

Implementing Page Objects in Playwright

Picture a login screen, such as The-Internet / Login


On this LoginPage, there is:
  • a heading: Login Page
  • a user name textbox with the label, "Username"
  • a password textbox with the label, "Password"
  • a login button, with the role of a button, and the name of "Login"
  • a flash message that appears if you enter invalid credentials such as "NotAUser" and "NotAPassword".
If you successfully log in with "tomsmith" and "SuperSecretPassword!, there is a SecureArea:
  • a heading: "Secure Area"
  • a flash message "You logged into a secure area!"
  • a Logout button. 
Sure, you could interact with each web element in your test... but what if the username text box locator changes? You would have to update multiple tests every time the element changed. 

... Instead, you could place it in a Page Object, something that Playwright handles well!

"A page object represents a part of your web application. An e-commerce web application might have a home page, a listings page and a checkout page. Each of them can be represented by page object models.

"Page objects simplify authoring by creating a higher-level API which suits your application and simplify maintenance by capturing element selectors in one place and create reusable code to avoid repetition".

Using Playwright's Built-In Test Runner? Or Something Else?


You may have noticed in https://playwright.dev/docs/pom that there are two different styles of page objects. One for "Test". One for "Library". 
  • Test: If you are writing actual Playwright test suites, and Playwright's built in test runner, use the Test section as a guide when creating page objects. 
  • Library: If you are integrating Playwright into an existing test framework such as Jest or Cucumber and just want browser automation, instead of having pre-built page fixtures, etc, you can use this format. 


Setting Up Page Objects


Let's review a page object I created for the main login page on my GitLab account for the bun-create-typescript project, tests/pages/LoginPage.ts.

The first thing we do is import the Locator method and Page fixture from the Playwright Test library.  
import { type Locator, type Page } from "@playwright/test";
According to Playwright.dev / Class-Fixtures, "Playwright Test is based on the concept of the test fixtures. Test fixtures are used to establish environment for each test, giving the test everything it needs and nothing else.

"Playwright Test looks at each test declaration, analyses the set of fixtures the test needs and prepares those fixtures specifically for the test. Values prepared by the fixtures are merged into a single object that is available to the test, hooks, annotations and other fixtures as a first parameter [...]

"Playwright Test will set up the page fixture before running the test, and tear it down after the test has finished. page fixture provides a Page object that is available to the test".

The Page and Locator Class


According to Playwright.dev / Page, "Page provides methods to interact with a single tab in a Browser, or an extension background page in Chromium. One Browser instance might have multiple Page instances.". These pages are isolated from each other, created for each test. The page fixtures have their own setup and teardown methods, such as opening up a new browser before a test starts, and closing a browser when a test ends. 

According to Playwright.dev / Locators, "Locators are the central piece of Playwright's auto-waiting and retry-ability. In a nutshell, locators represent a way to find element(s) on the page at any moment". The built-in Playwright locators are: 

Define the Page Object Class

Now that we have imported the Page and Locator class from Playwright, we can define the LoginPage page object class.

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly alertMessage: Locator;
  readonly heading: Locator;
Here, we are declaring LoginPage as a Page class, and creating Locator objects that will find and point to the username and password textboxes, the login button, the alertMessage, and the heading. 

Initialize the Page Object Class


Now that we have defined the page object, we can initialize a constructor for the page, setting up reusable locators for all the web elements we defined. 
  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByLabel("Username");
    this.passwordInput = page.getByLabel("Password");
    this.loginButton = page.getByRole("button", { name: "Login" });
    this.alertMessage = page.locator("#flash");
    this.heading = page.getByRole("heading", { name: "Login Page" });
  }
  • The textboxes are labeled "Username" and "Password". If we know that these labels are not expected to change, we can use the labels as a locator strategy. 
  • The login button, we can use as a locator strategy its role "button" and the name "Login".
  • The alert message is tricky. Here, we can grab the id, "#flash", in the locator.
  • The heading, we can also get by role of "heading", and name of "Login Page". 

Navigate to the Login Page


Let's embed in this Login Page Object a way to navigate to the Login page. 
  async navigate(): {
    await this.page.goto("/login");
  }
Here, we have defined a method in the LoginPage page object our tests can use. 

In our playwright.config.ts (see code in GitLab) we have already declared our baseURL to be https://the-internet.herokuapp.com, which will display when a new browser window is opened when a new test starts. 

When in our test we instantiate a new LoginPage method, and call the navigate method, it will goto "login". 
  • async has been placed before the function definition, since the browser will not be happening instantly. Actions will be happening asynchronously, all at the same time. It returns a "Promise" object.
  • await can be used inside an aync function. It tells the program to pause execution until the slow action, such as loading a webpage is finished. 
What is a Promise? "A Promise is a proxy for a value not necessarily known when the promise is created. It allows you to associate handlers with an asynchronous action's eventual success value or failure reason. This lets asynchronous methods return values like synchronous methods: instead of immediately returning the final value, the asynchronous method returns a promise to supply the value at some point in the future". - Developer.Mozilla.com / JavaScript / Promise.

What is Asynchronous Programming in JavaScript? "JavaScript, especially when running in a web browser environment, has a unique challenge. It's single-threaded, meaning it has one line of execution at a time. If any task (fetching data, responding to a button click, etc.) takes a long time, it would freeze the entire webpage!

"Asynchronous programming is the pattern to tackle this. It enables us to start a potentially long-running operation and tell JavaScript, 'Go about your business, I'll let you know when this is ready.' This prevents the webpage from locking up.

"[...] JavaScript async await lets us write asynchronous code that resembles the style of straightforward, step-by-step (synchronous) code. This often makes the logic much easier to follow.- Upgrad.com / tutorials / JavaScript aync-await.

Creating a LoginAs Method


Sure, we could fill in the username, fill in the password, and click the login button, but what if that login procedure changes? We would have to hunt down every time we logged into the app, and make the changes in the test. 

There is a software design principle called DRY: Don't Repeat Yourself, first coined by Andy Hunt and Dave Thomas in their book "The Pragmatic Programmer: From Journeyman to Master (1999)" (See Wikipedia, and the interview with both authors on YouTube at GoTo Conference 2020). 

Instead, we could create a new async method called "loginAs" where we pass in the username and password. 
  async loginAs(username: string, password: string): {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
Both "fill" and "click" are Playwright actions ( See docs ). 
  • Text input can use locator.fill
  • Checkboxes and radio buttons can .check them off, then expect them .toBeChecked. 
  • Multiple radio buttons can be .selectOptions by value or label. 
  • Mouse clicks can be performed with a mouse click, a double mouse click, a right click, a shift click, or a control click. 
  • You can press keys such as .press('Enter'), or .press('Control+ArrowRight').
  • You can also press keys such as Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape, ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight, ArrowUp, F1 - F12, Digit0 - Digit9, KeyA - KeyZ, etc
  • You can .dragAndDrop, and explicitly .scrollIntoViewIfNeeded().
We have the locator, such as this.usernameInput, that we have declared, so now we can perform an action on the locator. 

Get Text From Web Elements


Need to get text from an element such as an alert message or a heading? Using a locator, you can retrieve the innerText property of the web element. According to Mozilla Org / HTMLElement / innerText, "As a getter, it approximates the text the user would get if they highlighted the contents of the element with the cursor and then copied it to the clipboard".
  async getAlertMessage() {
    return (await this.alertMessage.innerText()).trim();
  }
Once we get the text of the web element, there may be hidden whitespaces in the text. "The trim() method of String values removes whitespace from both ends of this string and returns a new string, without modifying the original string". - Mozilla.Org / String / Trim

  async getHeading() {
    return (await this.heading.innerText()).trim();
  }
After getting the text and trimming it, we return it to our test function. 

We could have set up the page objects to expect the correct heading or alert message is correct, sending to the method what should be expected... but I try to avoid this whenever possible. 

Personally, I prefer to keep the assertions, the things we are testing for, in the actual test and out of the page object. 

Setting Up Test Data


Yes, with just one set of valid credentials, separating them into a JSON file is overkill... but what if there were hundreds of valid test users? Here we are placing them into a JSON (JavaScript Notation) file called credentials with two top level objects... valid, and invalid. 

In them, we have two key-value pairs in each object, username and password. 

tests/ data/ credentials.json
{
  "valid": {
    "username": "tomsmith",
    "password": "SuperSecretPassword!"
  },
  "invalid": {
    "username": "NotAUser",
    "password": "NotAPassword"
  }
}
Need to add or change usernames and passwords? 

No need to hunt through test data. Change just what is in the credentials file, and everything else will be reset. ( See this code in GitLab bun-create-playwright. )


Setting Up The Tests


Tests are set up in the Arrange / Act / Assert fashion, the pattern first mentioned by software developer Bill Wake in 2001 regarding the Extreme Programming movement (See XP123 / 3a- Arrange, Act. Assert ).
  • Arrange: Get the test data, open the browsers
  • Act: Navigate to the page and login 
  • Assert: Do we have the proper heading ("Secure Area"), url, and alert message that we have logged into a secure area? 
Finally, it's time to write our first test suite! Note: This code is in the GitLab project bun-create-playwright, under tests/login.spec.ts.
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
import { SecurePage } from "./pages/SecureAreaPage";
import credentials from "./data/credentials.json" with { type: "json" };
import messages from "./data/messages.json" with { type: "json" };
We are now importing files into our test and setting up variables to store the information such as:
  • The "test" and "expect" methods from Playwright. 
  • The LoginPage and SecureAreaPage page objects we have set up. 
  • The credentials stored in a JSON file that we have gone over.
  • A separate json file that stores all messages such as "You logged into a secure area!"


Set Up Constants for the Test Suite

test.describe("Login", () => {
  const validUser = credentials.valid.username;
  const validPassword = credentials.valid.password;
  const invalidUser = credentials.invalid.username;
  const invalidPassword = credentials.invalid.password;
Here, we are setting up a describe block, one of the methods in Playwright Test, declaring a group of tests, giving the test the title of "Login". 

Then, to make the test more readable, instead of passing a three part credentials call, I am storing them in appropriately named variables. 

These variables, validUser, validPassword, etc, we are declaring as type "const", also known as constants. If we accidentally change these values when the test is running, an error will be thrown. (Read more at TypeScript.org / Handbook / Variable Declarations ). 

These constants, since they are set up in the describe block, will be set for ALL of the tests in this test suite. 

Set Up the Individual Test


Let's set up a test that we can actually log in, and get to the secure area page, and call it "successful login with valid credentials".
  test("successful login with valid credentials", {
    tag: "@smoke",
  }, async ({ page }) => {
    const loginPage = new LoginPage(page);
    const securePage = new SecurePage(page);

    await loginPage.navigate();
    await loginPage.loginAs(validUser, validPassword);
We instantiate a new instance of the LoginPage, calling it a constant "loginPage", and do the same for the Secure Area.

Now that we declared new LoginPage, we can use the methods we have set up in our page objects, such as loginPage.navigate, and loginPage.loginAs, where we feed in the validUser and validPassword. 

How to Assert Our Test is Working


We have done the arranging. We have done the acting. Now, it is time for the assertions.
Playwright.dev / Assertions mention, "Playwright includes test assertions in the form of expect function. To make an assertion, call expect(value) and choose a matcher that reflects the expectation. There are many generic matchers like toEqual, toContain, toBeTruthy that can be used to assert any conditions.

"Playwright also includes web-specific async matchers that will wait until the expected condition is met.

"By default, the timeout for assertions is set to 5 seconds. Learn more about various timeouts".

You can assert that an element is attached, a checkbox is checked, an element is disabled, editable, am element is empty, enabled, focused, visible, contains text or has an exact piece of text, contains a CSS class, has an accessible description or name.

You can also check if it has a specific ARIA role or matches an Aria snapshot, has a certain amount of child elements, has a certain id, css, an option selected, a value.

With pages, you can expect it to have a certain screenshot (for visual testing), has a title, url, or an OK status.

All of these elements will be retried if they fail the first time.

You can also have some test conditions where you do not auto retry, such as expecting a value to be something, close to something, the value is falsy (false, 0, or null). You can expect the value to be greaterThan, greater than or equal, less than a certain value. You can expect it to be NaN (not a number), null, or truthy (not false, not 0, and not null). 

await expect(page).toHaveURL(/\/secure/);
    expect(await securePage.getHeading()).toBe("Secure Area");
    expect(await securePage.getAlertMessage()).toContain(messages.loginSuccessMessage);
  });
Now, we are checking that:
  • the heading we get from the SecurePage page object has the exact value "Secure Area".
  • the alert message matches the messages we have in the messages file... 
And that's how you set up Page Objects and Tests using Playwright! 

In the future, we will go over how to set up CI / CD pipelines with GitLab, but I need a break! 


Whew! That was a lot!

Happy Testing!

-T.J. Maher
Software Engineer in Test

BlueSky | YouTubeLinkedIn | Articles

No comments:

Post a Comment