Clean Test Cases Using Page Object Model (POM) in Playwright

Cerosh Jacob
4 min readMar 7, 2024
Photo by Brooke Lark on Unsplash

You may have seen numerous posts about creating a page object model in Playwright, but many of them, while simple, can quickly become cluttered when used for a large, real-time project. This is my solution to address those concerns and implement some best practices. The page classes are built based on the principles of the Page Object Model. We will use the Playwright test fixture, which is enhanced with a property. This fixture, used in the test scripts, enables us to perform actions in a cleaner, simpler, and more modular way. This approach facilitates easy maintenance and scalability.

This base file initializes a Playwright test fixture, known as test, for the whole project. Including this fixture in each test ensures an efficient and distinct environment for grouping. The test fixture, imported from @playwright/test, is renamed to base to avoid naming conflicts and improve clarity. The renamed fixture is then expanded and re-exported as test. Any test utilizing the extended test fixture can leverage the pre-existing features of the Playwright test fixture. Plus, it can initialize the LoginPage object using the login property in the tests.

import { test as base } from '@playwright/test';
import LoginPage from '../page-object-model/login.page';

export const test = base.extend<{
login: LoginPage;
}>({
login: async ({ page }, use) => await use(new LoginPage(page))
}
)

LoginPage the class represents the login page in the SauceDemo application. All application-representing classes follow a similar template. The class starts by importing Locator, Page, and expect from the @playwright/test package. These objects are used for web page representation, interaction with web page elements, and conducting assertions in Playwright tests. As the class uses default export, the LoginPage class is automatically imported when this module is referenced in the base page.

This class has properties defined as an instance of the Page class and instances of the Locator class. The instances of Locator class represent elements on the page that users interact with. Another property is a regular expression representing the expected title of the login page.

The constructor initializes the LoginPage object. It takes a page object as a parameter and sets the page property of the LoginPage object to the provided page. The constructor also initializes the properties using the page.locator() method. The playwright provides this method to locate elements on the page. Similarly, the constructor sets the loginPageTitle property to a regular expression, representing the expected title of the login page.

The loginToSauceDemo method is an asynchronous function. It navigates to the SauceDemo URL, verifies that the page title matches the expectation, then fills in the username and password fields, and finally clicks the login button.

A few things to highlight

  • Adopt some kind of naming conventions. In this case whenever a class property is needed to locate an element on the page, the type of element is also added. This approach helps avoid confusion in later stages.
  • Use the appropriate access and property modifiers. A property declared as private readonly can only be accessed and modified within the class where it's defined, restricting external access and modification. If this property needs to be shared across different classes, it should be defined as readonly, omitting the private keyword. The private keyword promotes encapsulation and data hiding, preventing other classes from directly accessing or modifying the values.
  • Using appropriate instance classes in TypeScript facilitates type safety, code maintenance, and refactoring.
  • Put related assertions within the corresponding page objects to separate test logic from page details. Using optional messages when adding assertions aids in identifying test failures and troubleshooting more easily.
import { Locator, Page, expect } from '@playwright/test';

export default class LoginPage {
private readonly page: Page;
private readonly userNameTextBox: Locator;
private readonly passwordTextBox: Locator;
private readonly loginButton: Locator;
private readonly loginPageTitle: RegExp;

constructor(page: Page){
this.page = page
this.userNameTextBox = page.locator('[data-test="username"]')
this.passwordTextBox = page.locator('[data-test="password"]')
this.loginButton = page.locator('[data-test="login-button"]')
this.loginPageTitle = /Swag Labs/
}

public loginToSauceDemo = async(): Promise<void> => {
await this.page.goto('https://www.saucedemo.com/');
await expect(this.page,"The comparison of the Saucedemo login page title was not successful.").toHaveTitle(this.loginPageTitle);
await this.userNameTextBox.fill('standard_user');
await this.passwordTextBox.fill('secret_sauce');
await this.loginButton.click();
}
}

This test utilizes the test fixture defined in basePage.ts. Individual steps of the test are outlined using test.step. Within each step, the login object from the fixture is used to execute the loginToSauceDemo method, which carries out the login action. The architecture separates concerns by defining page objects in one file (login.page.ts), test fixtures in a separate file (basePage.ts), and tests in yet another file (test.spec.ts). This structure enhances modularity and maintainability in your test suite.

things to notice

The test class contains only the most relevant information and is neatly organized.

There is only one import statement, which is the test fixture. This prevents the overcrowding of import statements when a test requires the use of many classes.

To utilize an object, we first create an instance of the class, pass the necessary parameters, and then assign it to a variable. This process can make the test appear untidy, but the test fixture can help streamline this.

Use test.step to break down tests into logical steps with descriptive names, improving clarity in test reports. If a test fails, the report highlights the failure point. test.step also has a boxed option to group actions in a single step, hiding them from the report if error-free, reducing clutter while allowing detailed step review when necessary.

import { test } from '../fixtures/basePage';

test.describe('Check out a random number of items.', async() =>{
test.step('Log in to the Sauce Demo application.', async ({ login }) => {
await login.loginToSauceDemo();
});
})

--

--