Clean Test Cases Using Page Object Model (POM) in Playwright
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 asreadonly
, omitting theprivate
keyword. Theprivate
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();
});
})
My recent publication compiled a comprehensive collection of 100 similar typescript programs. Each program not only elucidates essential Typescript concepts and expounds upon the significance of test automation but also provides practical guidance on its implementation using Playwright. This resource will undoubtedly be valuable if you look deeper into similar topics.