How to choose the best cloud platform for AI
Explore a strategy that shows you how to choose a cloud platform for your AI goals. Use Avenga’s Cloud Companion to speed up your decision-making.
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.
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.
Learn how to unlock your business potential with Cypress and Playwright. Read a story
The usual implementation of a complex web element on a page using classes typically looks like this:
import { Locator, Page } from '@playwright/test';
export class PlaywrightDevPage {
readonly page: Page;
readonly header: Header
constructor(page: Page) {
this.page = page;
this.header = new Header(page.locator('.header'));
}
}
class Header {
readonly header: Locator;
readonly logo: Locator;
readonly search: Locator;
readonly menu: Menu;
constructor(locator: Locator) {
this.header = locator;
this.logo = this.header.locator('.logo');
this.search = this.header.locator('.search-field');
this.menu = new Menu(this.header.locator('.nav_menu'));
}
}
class Menu {
readonly menu: Locator;
readonly docs: Locator;
readonly api: Locator;
constructor(locator: Locator) {
this.menu = locator;
this.docs = this.menu.locator('.docs');
this.api = this.menu.locator('.api');
}
}
The code above is a classical implementation of an object-oriented programming (OOP) approach that is quite easy to extend and fill with custom methods, but this approach will inevitably increase the size of your page object.
The same structure of a complex web element can be achieved in a different way:
import { $ } from 'playwright-elements';
export class PlaywrightDevPage {
readonly header = $('.header')
.subElements({
logo: $('.logo'),
search: $('.search-field'),
menu: $('.nav_menu')
.subElements({
docs: $('.docs'),
api: $('.api')
})
})
}
The code deploys the subElements
method from the Playwright-elements library to define the nested elements. It takes an object with keys representing the element names along with values representing the element selectors. Meanwhile, there is a mechanism for adding methods to all levels of nesting in such complex web elements:
import { $, WebElement } from 'playwright-elements';
export class PlaywrightDevPage {
readonly header = $('.header')
.subElements({
logo: $('.logo'),
search: $('.search-field'),
menu: $('.nav_menu')
.subElements({
docs: $('.docs'),
api: $('.api')
})
.withMethods({
async navigateToDocs(this: WebElement & { docs: WebElement }) {
await this.docs.click();
}
})
})
}
The withMethploys
the subElements method from the Playods method is used here to add a custom method called navigateToDocs
to the menu element. It is linked to the docs element in order to navigate to the documentation page. Generally speaking, we can easily navigate and interact with the nested elements of a complex web page with this code.
Expanding on the example above, let’s cover the case for an adaptive header variant. Please note the different implementations of the navigateToDocs
method. Often, the mobile version of a website requires specific differences to implement the same actions:
import { $, WebElement, initDesktopOrMobile } from 'playwright-elements';
const desktopHeader = $('.header')
.subElements({
logo: $('.logo'),
search: $('.search-field'),
menu: $('.nav_menu')
.subElements({
docs: $('.docs'),
api: $('.api')
})
.withMethods({
async navigateToDocs(this: WebElement & { docs: WebElement }) {
await this.docs.click();
}
})
});
const mobileHeader = $('.mob-header')
.subElements({
logo: $('.mob-logo'),
search: $('.mob-search-field'),
menu: $('.mob_nav_menu')
.subElements({
docs: $('.docs'),
api: $('.api')
})
.withMethods({
async navigateToDocs(this: WebElement & { docs: WebElement }) {
await this.hover();
await this.click();
await this.docs.click();
}
})
});
export class PlaywrightDevPage {
readonly header = initDesktopOrMobile(desktopHeader, mobileHeader);
}
This code snippet has two different implementations of the navigateToDocs
method, depending upon whether the header is viewed on a desktop or mobile device. We defined two different page elements – desktopHeader
and mobileHeader
. They represent the header of a website for desktop and mobile devices, respectively. Each header element contains nested elements embodying various parts of the header, such as the logo, search field, and navigation menu. Additional actions are performed in the mobile header implementation before clicking on the “docs” link. Specifically, the header element is hovered over, and then the “docs” link is clicked.
A PlaywrightDevPage
class contains a single header property that is initialized via the initDesktopOrMobile
function. Based on the device type, it chooses either the desktopHeader
or mobileHeader
. This strategy allows the header property to be run in tests seamlessly. You can find more detailed information on all the methods used in our code examples in the documentation linked at the beginning of the article.
Playwright-elements offers advanced functionality that enhances the capabilities of Playwright. As a powerful extension, it provides optimal solutions for maintaining and reusing test code, and in this way, reduces fixture duplication and eliminates the need for traditional page object structures. The adoption of Playwright-elements also allows automation engineers to ensure that their tests are more resilient to changes in the UI and browser environment, and can be run across multiple browsers with ease. As such, Playwright-elements is an essential tool for professionals who want to create robust and scalable end-to-end testing workflows.
Avenga is an international tech company with deep industry knowledge in pharma, insurance, finance, and automotive. The company’s IT specialists operate from 8 countries around the world, supporting digital transformation with projects along the entire digital value chain – from digital strategy to the implementation of software, user experience, and IT solutions, including hosting and operations. Interested in building a transparent and productive technology partnership? Contact us.
* US and Canada, exceptions apply
Ready to innovate your business?
We are! Let’s kick-off our journey to success!