The Playwright-elements extension provides hidden opportunities for seamless automated testing.
Playwright is an open-source library designed for cross-browser web testing and automation. Its versatility and flexibility makes it possible to create complex end-to-end testing workflows. However, as test suites become increasingly complex and multilayered, maintaining and reusing test code becomes a challenging task. That’s where Playwright-elements comes in. As an extension with advanced functionality, it offers a fresh perspective on the common challenges faced by test automation engineers.
In this article, we will discuss how to tap into the full potential of the Playwright library and explore the different aspects of UI tests in automated testing. We will tackle several questions, including how to improve your page object structure (or even eliminate it), how to add lazy initialization to reduce constant fixture duplication in tests, and how to create complex web elements without generating a large number of classes. With the help of Playwright-elements, we will discover optimal ways to improve the maintainability, reusability, and scalability of your test suite.
Lazy initialization challenge
For those who have already had a chance to get familiar with Playwright or have even switched to it at work, the following code from the official documentation on creating a page object with the Playwright library will definitely look familiar.
This code snippet is taken from the official Playwright guide on writing a page object.
import { expect, Locator, Page } from '@playwright/test';
export class PlaywrightDevPage {
readonly page: Page;
readonly getStartedLink: Locator;
readonly gettingStartedHeader: Locator;
readonly pomLink: Locator;
readonly tocList: Locator;
constructor(page: Page) {
this.page = page;
this.getStartedLink = page.locator('a', { hasText: 'Get started' });
this.gettingStartedHeader = page.locator('h1', { hasText: 'Installation' });
this.pomLink = page.locator('li', { hasText: 'Guides' }).locator('a', { hasText: 'Page Object Model' });
this.tocList = page.locator('article div.markdown ul > li > a');
}
async goto() {
await this.page.goto('https://playwright.dev');
}
async getStarted() {
await this.getStartedLink.first().click();
await expect(this.gettingStartedHeader).toBeVisible();
}
async pageObjectModel() {
await this.getStarted();
await this.pomLink.click();
}
}
The class PlaywrightDevPage
represents a page object for the Playwright website. It embodies a pattern that encapsulates page-specific functionality into a separate object. In this way, we can interact with the Playwright website via the pageObjectModel()
method in order to navigate a specific page and perform actions.
This code follows established practices for creating page objects with Playwright, but we can improve it in the following way:
import { expect, Locator, Page } from '@playwright/test';
export class PlaywrightDevPage {
readonly page: Page; // declaration without initialization
readonly getStartedLink: Locator;
readonly gettingStartedHeader: Locator;
readonly pomLink: Locator;
readonly tocList: Locator;
constructor(page: Page) { // initialization in constructor, mainly because it's not possible without an instance of the page
this.page = page; // initialization locators beforehand. So simple declaration and initialization take twice as much space as they should.
this.getStartedLink = page.locator('a', { hasText: 'Get started' });
this.gettingStartedHeader = page.locator('h1', { hasText: 'Installation' });
this.pomLink = page.locator('li', { hasText: 'Guides' }).locator('a', { hasText: 'Page Object Model' });
this.tocList = page.locator('article div.markdown ul > li > a');
}
}
Here, the Page
object, which represents the web page being tested, is passed into the PlaywrightDevPage
constructor and stored as a readonly
property. In order to use the PlaywrightDevPage
methods in tests, a new instance of PlaywrightDevPage
must be created with a new instance of Page
passed in each time. This leads us to the fact that we have to initialize the page again and again in each test:
import { test } from ‘playwright-elements’;
test.describe(`suite`, () => {
test('first test', async ({ page }) => {
const devPage = new PlaywrightDevPage(page);
});
test('second test', async ({ page }) => {
const devPage = new PlaywrightDevPage(page);
});
});
In this sample code, there are two tests defined within the suite test suite. Each test creates a new instance of the PlaywrightDevPage class
, which is defined elsewhere in the code, and passes the page
object provided by Playwright as a parameter to the PlaywrightDevPage
constructor.
In many cases, there is an intent to reuse an instance of a single page between tests, especially considering the specific nature of Playwright which runs parallel tests for different employees. In this scenario, each person creates their own instance of the page
object, which leads to duplication of work. As a result, initializing pages at the beginning of each test or within a hook (which looks a bit better) creates unnecessary friction in the code.
A compact solution to this issue would look like this:
import { $ } from ‘playwright-elements’;
export class PlaywrightDevPage {
readonly getStartedLink: $(‘a’).hasText('Get started');
readonly gettingStartedHeader: $('h1’).hasText(‘Installation’);
readonly pomLink: $(‘li’).hasText('Guides’).$(‘a’).hasText(‘Page Object Model’);
readonly tocList: $('article div.markdown ul > li > a');
}
In this improved version, the locators are initialized as class members. There is no need to build a separate constructor. This approach creates an opportunity to reuse the same instance of the page object between tests and simplify the code. We also use the $
function from playwright-elements
to create locators, making the code more concise and easier to read. Now, the tests do not require local initialization of your page object:
test.describe(`suite`, () => {
const devPage = new PlaywrightDevPage();
test('first test', async () => {
await devPage.tocList.first().click();
});
test('second test', async () => {
await devPage.tocList.last().click();
});
});
Also, such web elements from Playwright-elements can live outside the page object.
components.ts
import { $ } from ‘playwright-elements’;
export const header = $(‘.nav-bar’);
In this code snippet, the web element nav-bar
is defined as a constant named header
in a separate file named components.ts
. We make it reusable in other parts of the code and eliminate the need to instantiate a page object first by defining this element outside of the PlaywrightDevPage
class.
Now, you can use the existing object in the test:
import { test } from ‘playwright-elements’;
import { header } from ‘./components’;
test.describe(`suite`, () => {
test('first test', async () => {
await header.click();
});
});
The previously defined web element header from the components.ts file is imported into the test file in this code snippet. We can then use the click() method on the header element in the test() function in order to avoid instantiating a new instance of the page object or the PlaywrightDevPage class. Consequently, we can define commonly used web elements in a separate file and create a library of reusable components that is easily imported into different tests.