Make Engineers Love Playwright With These Software Design Patterns

When you talk about test automation frameworks, it won’t be long until someone mentions Selenium, often written in Java or C#. Selenium was and remains a popular test automation tool. As the discipline of Quality Engineering has grown, it has become obvious that for maintainability purposes automated tests should match the language used in the application under test. This started to give life to newer test frameworks, like Cypress, NightWatch, WebdriverIO, and Puppeteer just to name a few that support JavaScript. However, when deciding on a test framework many would still decide on Selenium as it has huge community support, lots of resources available online, and the most experienced Quality Engineers know it by heart. When Playwright arrived a few years ago, offering language support not only for Node but also Object Oriented Programming languages like Java or C#, it began to catch the eye of lots of Quality Engineers.

On the business-to-business side of Houseful we have a software product called Alto (a Customer Relationship Management product for Estate Agents). We had developed a C# Selenium framework over years, and as well as it had served us, it wasn’t without issues. When we started building out new micro front end experiences for Alto, we experimented with a range of the new frameworks and settled on using Playwright in Typescript to test these new experiences. It was powerful, fast and reliable. Developers and Quality Engineers enjoyed working with Playwright, and it wasn’t long until the topic of migrating from Selenium to Playwright for our existing tests came up.

There were a few benefits that supported this idea:

  • Playwright uses a real browser to execute tests, so there are no issues with opening a new window, new tab, uploading/download files, accessing the console or network tab
  • It is a single API to handle UI, API, and visual comparison tests
  • In build auto-waits
  • It makes sense to test JS that runs in a browser with JS/TS framework
  • It unifies our UI driven testing for the product across new and existing experiences

Structural Design Patterns - Page Object Model

If you worked with Java or C# tests before switching to Node tests you may notice that there is a lot of repetition in this example.spec.js file.

test('test', async ({ page }) => {
 await page.goto('http://localhost:4200/#/login');
  await page.locator('input[type="password"]').click();
  await page.locator('input[type="password"]').fill('password');
  await page.locator('input[type="password"]').press('Enter');
  await page.locator('[aria-label="properties"]').click();
  await page.locator('text=Search').click();
  await expect(page).toHaveURL('http://localhost:4200/#/properties/search');
  await page.locator('input').click();
  await page.locator('placeholder=address...').click();
  await page.locator('button:has-text("Find all")').click();
  await page.locator('[aria-label="menu"]').click();
  await page.locator('text=Recent Properties').click();

If you want to test another user journey on this home page you will have to repeat some of this code, this test is not readable, and is difficult to say what the user journey tested here actually looks like, there are web elements mixed here with actions. It’s just begging for a Structural Design pattern - the Page Object Model !

I think Playwright introduced support of the page object model approach to help us object oriented programming-exposed minds with migration to Playwright while maintaining great Node support.

Using Page Object Model in test abstracts page interactions and definitions outside of spec files that allows for tests:

  • to be more readable:
await searchPage.lookForRental("flat", 2, "Glasgow");
await searchPage.sortByPrice();
  • to avoid repetition:
await searchPage.lookForRental("house", 3, "London");
await searchPage.lookForRental("flat", 2, "Glasgow");
  • to follow the separation of concerns principle:
await searchPage.lookForRental("flat", 2, "Glasgow");
await searchPage.selectFirstResult();
await propertyPage.viewFloorPlan();

About Page Object Model

When it comes when to creating your Page Object Models there are a few rules to keep in mind and so that they align with SOLID principles of building software:

S - The Single Responsibility Principle - methods defined in class should have a single role because if there will be a change required we should only change it in one place:

async viewFloorPlan: Promise<void>() {

O - The Open Closed Principle - open for extension but closed for modification - the ability to expand on the class behavior without modifying it. A good example here is using types as method parameters - we can always expand on types but the method won’t accept a string as a parameter.

const PropertyStatus: Record<string, number> = {
  instructed: 343,
  available: 108,
  withdrawn: 210,
  archived: 117,
} as const;
await this.changePropertyStatus(

L - The Liskov Substitution Principle - a subclass should require nothing more and promise nothing less - a reminder: do I need to extend HomePage from BaseClass or should I use a helper method instead? To favor composition over the inheritance.

I - The Interface Segregation Principle - should I use the existing, similar interface or should I create a new one?

D - The Dependency Inversion Principle - using Page Object Model for tests is a great example of following this rule. The class methods shouldn't include too many details, if they are complex is best to follow the single responsibility principle and create separate methods to handle detailed behaviour.

async createAccount: Promise<void>() {
  await this.completePersonalDetails();
  await this.setPreferences();
  await this.acceptTCModal();

The ExamplePage class:

  • will have encapsulated web elements (locators) as instance variables and methods that can be used across all tests
  • will use a new keyword to create the instance of the class
  • will be separated from tests in the Page Object Model directory
  • won't have assertions within the class

Data creation - Service Object Model

How do you handle test data creation in Selenium? Do you create your test data via UI web elements interaction, or maybe have a separate class for it? Are you using RestAssured to help you with creating test data via API calls?

What is great about Playwright is the fact that it's a single API to handle browser, API, and visual regression tests so for test data creation we can use its API testing feature.

The PropertyData class will follow page object model principles and separate property data creation concerns but will be instantiated with the Playwright apiContext instead of the page - Service Object Model.

const propertyData = new PropertyData(apiContext);

That gives access to request fixtures so executing API calls like GET, PUT, POST, and DELETE. This will be a facade for API calls to create test property, set it in appraisal state, and make it available and archived.

const propertyId = await propertyData.addNewProperty();

In Service Object Models we use a Factory pattern that separates the object creation method from the class:

const property = buildPropertyPayload(userId, this.branchId);

Ideally, as it is the separate concern of API-related classes, it will be stored in the Services directory.

Creational design patterns - what Playwright fixtures are for

Your tests may cover complex user journeys that interact with many pages. In order to make the required page objects available, to do this you create instances of the page class in the BeforeEach hook in your spec file. When we think about a test suite with 20 spec files this can mean a lot of repetition of code. With the use of advanced Playwright fixtures, you can create an instance of each Page Object Model in the fixture:

exports.test = base.test.extend({
  searchPage: async ({ page }, use) => {
    const searchPage = new SearchPage(page);
    await use(searchPage);
  propertyPage: async ({ page }, use) => {
    const propertyPage = new PropertyPage(page);
    await use(propertyPage);

To then use it in the test:

test("search test", async ({ searchPage }) => {
  await searchPage.lookForRental("flat", 2, "Glasgow");

Fixtures also allow you to build isolated Service Object Models that use API context and baseURL or authentication headers that might be different than in other fixtures.

propertyData: async ({ playwright, baseURL }, use) => {
  const BASE_URL = `${baseURL}/api`;
  const headersList = {
    "Accept": "*/*",
    "Authorization": `Basic ${process.env.AUTH_TOKEN}`
  const apiContext = await playwright.request.newContext({
    baseURL: BASE_URL,
    extraHTTPHeaders: headersList,
  const propertyData = new PropertyData(apiContext);
  await use(propertyData);

Initial Findings

We are still early in the process of migrating our Selenium tests to Playwright. However, those we have migrated we have running in our pipelines and this has given us some great feedback and a clear view of improvements:

  • Speed: Playwright tests are much faster than Selenium ones that cover the same user journey
  • Autowait of async Playwright methods allowed us to discard all Selenium wait helpers
  • Threads: Playwright tests allow us to run tests in parallel in an isolated Web worker context
  • Tests can be run in a pipeline using GitHub Actions while still maintaining high quality reporting. This allowed us to remove the dependency of 3rd party test runners
  • Debugging: As a job artifact we save: screenshots, traces, and recordings of the test which allows us to identify the root cause of tests failure much quicker than our Selenium implementation

The improvements we have seen so far have made it clear that migrating the rest of our tests to Playwright will be highly valuable. We are all really excited about not only the improvements, but what else we will learn along the way. We’ll be sure to keep you updated.