Blog>>Quality assurance>>Testing>>Page Object Model with Playwright and TypeScript

Page Object Model with Playwright and TypeScript

Page Object Model (POM) is a design pattern that plays a crucial role in maintaining a structured and organized approach in automated testing, particularly in the context of web application testing. By encapsulating the underlying page structure and elements within objects in the code, POM separates test logic from the UI-specific selectors and actions. This separation allows code reusability, maintainability, and readability, making it significantly easier to manage test cases and handle changes in the UI over time. Through this arrangement, test developers can create a robust framework that can withstand the evolving nature of web applications while ensuring that the tests remain effective and easy to comprehend.

Project setup

An example project was built using Playwright with TypeScript, describing practices, and guidelines applicable to any programming language, as well as any tool, because Page Object Model is widely used among different projects for end-to-end (e2e) testing.

Prerequisites:

  • LTS (long-term support) node installed - this project was built on v18.17.1.
  • Visual Studio Code as IDE (recommended).
  • Visual Studio Code extension Playwright Test for VSCode - official Microsoft release (recommended). 

Open IDE with your terminal of choice at the project location and run:

npm init playwright, the latest version 1.38.1 will be installed.


The example tests provided by Playwright can be removed. For a more detailed description of project setup please see the Introduction to Playwright article, which goes more in-depth on understanding the initialized folder structure.

Creating tests 

The created tests will go through refactoring, along with introducing new things into the project from plain test scripts to different design patterns and seeing what they bring to the table.

A few demo tests that will be created here will focus on checking whether searching and applying different tags on the https://codilime.com/resources/ (https://web.archive.org/web/20231013123303/https://codilime.com/resources/      link-icon is the archived state of the page at the moment of writing this article) page are working correctly and displaying the expected resources as well as navigating through the Codilime tabs from the navbar. 

  1. Before writing the first test, remove additional projects from playwright.config.ts to use only Chromium, as it won’t be necessary to run the test on different browsers.
    file: playwright.config.ts

    1. projects: [
        {\
      name: 'chromium',\
      use: { ...devices\['Desktop Chrome'] },
    
       },\
    ]
  2. Create the first spec codilime-resources.spec.ts file containing a test that ensures the correct resources are displayed after using the search bar, content type tag, or topic tag.

     import { test, expect } from '@playwright/test';
    
    
    
    test('Search Golang resources with search bar, async ({ page }) => {
    
        await page.goto('https://codilime.com/resources/');
    
        await page.getByRole('button', { name: 'Accept All Cookies' }).click();
    
        await page.locator('input\[id="resources-input"]').fill("golang")
    
        await expect(page.locator('a\[href="https://resources.codilime.com/golang-checklist/"]')).toBeVisible();
    
    });
    
    
    
    test('Search VELES resources with content tag', async ({ page }) => {
    
        await page.goto('https://codilime.com/resources/');
    
        await page.getByRole('button', { name: 'Accept All Cookies' }).click();
    
        await page.getByRole('button', { name: 'Open source' }).click();
    
        await expect(page.locator('a\[href="https://codisec.com/veles/"]')).toBeVisible();
    
    });
    
    
    
    test('Search UX in network applications with UI&UX tag', async ({ page }) => {
    
      await page.goto('https://codilime.com/resources/');
    
      await page.getByRole('button', { name: 'Accept All Cookies' }).click();
    
      await page.getByRole('button', { name: 'UX & UI' }).click();\
      await expect(page.locator('a[href="[https://events.codilime.com](https://events.codilime.com/) /ux-in-network-applications"]')).toBeVisible();
    
    });

Run npx playwright test to check if everything works; three tests should pass.

Adding Page Object Model (POM) 

Create a new folder named page-object containing a new file codilime-resources.page.ts that will store the page object for the Codilime resources page.

import { Page } from '@playwright/test';

export class ResourcesPage {
  constructor(private page: Page) {}

  public cookieButton = this.page.getByRole('button', { name: 'Accept All Cookies' });
  public searchInput = this.page.locator('input[id="resources-input"]');
  public openSourceButton = this.page.getByRole('button', { name: 'Open source' });
  public uxuiButton = this.page.getByRole('button', { name: 'UX & UI' });
  public golangLink = this.page.locator('a[href="https://resources.codilime.com/golang-checklist/"]');
  public valesLink = this.page.locator('a[href="https://codisec.com/veles/"]');
  public uxAppLink = this.page.locator('a[href="https://events.codilime.com/ux-in-network-applications"]');

  async navigate() {
    await this.page.goto('https://codilime.com/resources/');
  }

  async acceptCookies() {
    await this.cookieButton.click();
  }

  async fillSearchInput(text: string) {
    await this.searchInput.fill(text);
  }

  async clickOpenSourceButton() {
    await this.openSourceButton.click();
  }

  async clickUxuiButton() {
    await this.uxUiButton.click();
  }
}

All the locators used are assigned to different variables and then used if needed inside of different methods that represent actions on the page, like filling fields or clicking on elements.

Refactor codilime-resources.spec.ts to use the Page Object Model that was just created.
The ResourcePage class will be imported to the spec file and initialized to gain access to all methods inside.

import { test, expect } from '@playwright/test';
import { ResourcesPage } from '../page-objects/codilime-resources.page';

test('Search golang resources with search bar', async ({ page }) => {
  const resourcesPage = new ResourcesPage(page);
  await resourcesPage.navigate();
  await resourcesPage.acceptCookies();
  await resourcesPage.fillSearchInput('golang');
    await expect(resourcesPage.golangLink).toBeVisible();
});

test('Search VELES resources with Open source content type tag', async ({ page }) => {
  const resourcesPage = new ResourcesPage(page);
  await resourcesPage.navigate();
  await resourcesPage.acceptCookies();
  await resourcesPage.clickOpenSourceButton();
    await expect(resourcesPage.valesLink).toBeVisible();
});

test('Search UX in network applications with UI&UX topic tag', async ({ page }) => {
  const resourcesPage = new ResourcesPage(page);
  await resourcesPage.navigate();
  await resourcesPage.acceptCookies();
  await resourcesPage.clickUxuiButton();
  await expect(resourcesPage.uxAppLink).toBeVisible();
});

Check everything still passes with the npx playwright test.

As shown, every “hardcoded” selector is assigned to variables and moved into the same class. In case of any changes, we only have to replace the locator in one place, providing great reusability and maintainability and good naming convention test logic. 

>> Read more about test automation services by CodiLime.

Page Components Object 

Page Component Object is an approach to writing robust automated e2e tests, aiming for even greater organization and modularity. In complex web applications it is easy to imagine that certain modules or features will be available on a lot of different pages; the most common reappearing elements on web pages are navigation bars or footers. 

For this project, a repeating theme is a popup with cookies that will appear wherever the test starts navigating first; if the contact form from https://codilime.com/contact/ is  to be tested, at this moment, the cookies popup would be imported from the ResourcesPage class.

Create a new page-components folder at the root directory containing the file cookies.component.ts. The content of the component objects files follows the same rules as the one with page objects but refers mostly to the specific component, not the whole page. 

export class CookiesComponent {
  constructor(private page: Page) {}

  public cookiesSettingsButton: Locator = this.page.getByRole('button', { name: 'Cookies Settings' });
  public acceptAllCookiesButton: Locator = this.page.getByRole('button', { name: 'Accept All Cookies' });

  async openCookiesSettings(): Promise<void>  {
    await this.cookiesSettingsButton.click();
  }

  async acceptAllCookies(): Promise<void>  {
    await this.acceptAllCookiesButton.click();
  }
}

With the new CookiesComponent class created it can be implemented in the original test suite in codilime-resources.spec.ts.

import { test, expect } from '@playwright/test';
import { ResourcesPage } from '../page-objects/codilime-resources.page';
import { CookiesComponent } from '../page-components/cookies.component';

let resourcesPage;
let cookiesPage;

test.beforeEach(async ({ page }) => {
  resourcesPage = new ResourcesPage(page);
  cookiesPage = new CookiesComponent(page);
  await resourcesPage.navigate();
  await cookiesPage.acceptAllCookies();
});

With a component that can appear at any point on the main Codilime page, it is much easier to adjust to any future changes and easier to locate this part of the webpage, in comparison to having it inside of codilime-resources.page.ts.

Pros and cons of using POM

Even though the Page Object Model gives a feeling of more professional code it is important to plan POM implementation thoroughly, as it can get quickly out of hand if done improperly.

Pros:

  • Reusability: Page methods can be reused across different test cases. Created page objects are reusable across all tests, which makes the code more maintainable.
  • Maintainability: When the UI changes, it is unnecessary to update every test, but just the page object. This makes the test suite much easier to maintain.
  • Readability: Tests become more readable and understandable, as a higher level of abstraction is implemented with operations in tests described in terms of page operations.
  • Separation of concerns: This separates tests from navigation code. The main goal of tests is to ensure the expected state on the page, thus assertions should be inside of the test logic and separated from UI interactions which are held in page objects files. 
  • Reliability: This helps make the tests more robust. By having all page interaction logic in one place, any strategies for waiting for elements or interacting with elements are consistently applied.

Cons:

  • Initial effort: Setting up the Page Object Model requires an initial investment of time and effort, which might seem unnecessary for small projects.
  • Complexity: For new team members, or those unfamiliar with the pattern, it can be complex and hard to understand.
  • Overhead: For very simple apps or tests, POM can add an additional layer of abstraction and that time could be spent on higher-priority tasks.
  • Maintenance: If not designed carefully, the page object layer itself can become a maintenance burden.
  • Performance: While generally minimal, there can be a slight hit to performance, particularly if the page objects are not well designed or if they are overly complex, as this could lead to longer execution times for tests.

Playwright’s fixtures

Create new folder fixtures with file resources.fixture.ts containing wrapped page objects and components - these should be split into separate files, but for the sake of simplicity are shown together. 

import { test as baseTest } from '@playwright/test';
import { ResourcesPage } from '../page-objects/codilime-resources.page';
import { CookiesComponent } from '../page-components/cookies.component';

export const test = baseTest.extend<{ resourcesPage: ResourcesPage, cookiesComponent: CookiesComponent }>({
  resourcesPage: async ({ page }, use) => {
    await use(new ResourcesPage(page));
  },
  cookiesComponent: async ({ page }, use) => {
    await use(new CookiesComponent(page));
  }
});

Refactor codilime-resources.spec.ts to use custom fixtures:

import { expect } from '@playwright/test';
import { test } from '../fixtures/resources.fixture';

test.beforeEach(async ({ cookiesComponent, resourcesPage }) => {
  await resourcesPage.navigate();
  await cookiesComponent.acceptAllCookies();
});

test('Search golang resources with search bar', async ({ resourcesPage }) => {
  await resourcesPage.fillSearchInput('golang');
  await expect(resourcesPage.golangLink).toBeVisible();
});

test('Search VELES resources with Open source content type tag', async ({ resourcesPage }) => {
  await resourcesPage.clickOpenSourceButton();
  await expect(resourcesPage.valesLink).toBeVisible();
});

test('Search UX in network applications with UI&UX topic tag', async ({ resourcesPage }) => {
  await resourcesPage.clickUxuiButton();
  await expect(resourcesPage.uxAppLink).toBeVisible();
});

This approach makes your tests cleaner and ensures that the ResourcesPage and CookiesComponent are set up and torn down properly for each test. Even though fixtures still require the creation of page objects, they allow us to skip creating instances of classes inside the test logic, making it much cleaner. 

Here, we encourage you to check our previous publication about what is Playwright.

Don’t Repeat Yourself 

The DRY principle is a fundamental concept in software development aimed at reducing the repetition of information or code in multiple areas. It promotes the idea that each piece of code should have a single representation within a system.

Playwright allows a set of hooks, like before, beforeEach, after, afterEach to set up and tear down test data for the whole test suite or every single test scenario. 

Refactoring for the DRY principle includes:

  • Declaration of the resourcesPage at the suite level so that it can be accessed by all tests.
  • Performing navigation and cookie acceptance before each test, removing the need for each individual test to do so. Each test then directly starts with the actions specific to it.

Playwright’s tests operate in an isolated environment; that’s why actions such as accepting cookies are required every time. 

import { test, expect } from '@playwright/test';
import { ResourcesPage } from '../page-objects/codilime-resources.page';

let resourcesPage;

test.beforeEach(async ({ page }) => {
  resourcesPage = new ResourcesPage(page);
  await resourcesPage.navigate();
  await resourcesPage.acceptCookies();
});

test('Search golang resources with search bar', async () => {
  await resourcesPage.fillSearchInput('golang');
  await expect(resourcesPage.golangLink).toBeVisible();
});

test('Search VELES resources with Open source content type tag', async () => {
  await resourcesPage.clickOpenSourceButton();
  await expect(resourcesPage.valesLink).toBeVisible();
});

test('Search UX in network applications with UI&UX topic tag', async () => {
  await resourcesPage.clickUxuiButton();
  await expect(resourcesPage.uxAppLink).toBeVisible();
});

TypeScript types

Using types in TypeScript (or any check type mechanism in other languages), especially in patterns like the Page Object Model, is pivotal for creating self-documenting code, enhancing readability, and providing clear context for future maintenance. Types empower developers by catching errors at compile-time, and promoting robust and reliable code. They improve developer efficiency with enhanced tooling support such as intelligent autocompletion (e.g. IntelliSense in Visual Studio Code).

Overall, types serve as the foundation for maintainable, scalable, and bug-resistant test development.

import { Locator, Page } from '@playwright/test';

export class ResourcesPage {
  constructor(private page: Page) {}

  public cookieButton: Locator = this.page.getByRole('button', { name: 'Accept All Cookies' });
  public searchInput: Locator = this.page.locator('input[id="resources-input"]');
  public openSourceButton: Locator = this.page.getByRole('button', { name: 'Open source' });
  public uxuiButton: Locator = this.page.getByRole('button', { name: 'UX & UI' });
  public golangLink: Locator = this.page.locator('a[href="https://resources.codilime.com/golang-checklist/"]');
  public valesLink: Locator = this.page.locator('a[href="https://codisec.com/veles/"]');
  public uxAppLink: Locator = this.page.locator('a[href="https://events.codilime.com/ux-in-network-applications"]');

  async navigate(): Promise<void> {
    await this.page.goto('https://codilime.com/resources/');
  }

  async acceptCookies(): Promise<void> {
    await this.cookieButton.click();
  }

  async fillSearchInput(text: string): Promise<void> {
    await this.searchInput.fill(text);
  }

  async clickOpenSourceButton(): Promise<void> {
    await this.openSourceButton.click();
  }

  async clickUxuiButton(): Promise<void> {
    await this.uxuiButton.click();
  }
}

SOLID principles

Currently in the modern world of software development, there are a lot of principles and good practices for implementing different blocks of code. However, SOLID is one of the most popular ones (other similar acronyms worth mentioning are GRASP and CUPID). The SOLID principles are a set of five design guidelines that enhance a software application's maintainability, scalability, and flexibility by providing a pathway for creating clean, easy-to-understand code.

S - Single Responsibility Principle

O - Open/Closed Principle

L - Liskov Substitution Principle

I - Interface Segregation Principle

D - Dependency Inversion Principle

These principles are best described as guidelines, which is why there is no need to jump straight to the code and refactor it to strictly respect these ideas - a lot of people are implementing those principles using just common sense. 

Two of the SOLID principles were introduced in the context of the Page Object Model.

Single Responsibility Principle: A class or module should have one, and only one, reason to change.

Each page object is responsible for the interactions with a single page (or part of a page) in a web application. A login page that has a LoginPage class responsible for encapsulating all the functionalities and elements of that page, such as filling in the form and submitting it, should not contain information about, for example, the footer or navigation bar. This separation simplifies maintenance.

In the case of UI changes, it is not necessary to go through individual test cases to make updates. This keeps test scripts clean and high-level, narrowing quickly to the assertions, while the page objects deal with the lower-level operations.

Open/Closed Principle: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Page objects can be extended to add new functionality without necessarily modifying their existing code. If new features are added to a page, it is possible to extend the corresponding page object without affecting existing methods. This way, tests that use the old version of the page object won't break, and test developers can gradually add tests for the new features. This makes your test framework more resilient to changes in the application and allows it to grow without causing issues with existing test cases.

It enhances the reusability and maintainability of your test code by keeping existing code working when you need to extend or change functionality.

Services test automation

Design patterns in POM

People have been writing different computer programs for decades, leading to the repetition of the most popular implementations and best practices on how to approach them. Design patterns are 23 typical solutions to common problems in software design, grouped into three main categories: creational, structural, and behavioral. They serve as ready-to-use recipes for common design problems. In specific projects that utilize Page Object Models, it is also beneficial to be aware of those patterns and consciously implement their design. 

Factory Method: Instead of calling the constructor directly to create a page object, the Factory Method is called to get an instance of the page. This method can be particularly useful if the creation process is complex, involves logic, or needs to be repeated across different parts of the testing suite.

Singleton: This ensures that a class has only one instance and provides a global point of access to it. In the context of POM, you might use the Singleton pattern for pages or components that are common across multiple test cases and don't change state between tests. This helps improve performance and reduce resource usage. Using parallelization with Singletons should also be addressed before implementation.

Composite: This can be used to treat grouped objects or components (like forms, sections, or navigation bars) as a single instance of an object. This is useful in POM when a page can be divided into multiple sections which can be treated as separate objects. Each section can be a composite of more components.

By knowing and applying the right design patterns, teams can build more robust, adaptable, and efficient testing frameworks, leading to higher-quality software and more efficient development cycles.

Summary

The Page Object Model has a foundational role in enhancing test automation by encapsulating the UI in usable objects. Assessing the benefits of POM, such as improved test maintenance and readability, and bearing in mind a few obstacles, it is a must-have for every project that is not developed by a single person. POM is a widely used pattern across different frameworks and programming languages; it can be utilized in the form of fixtures in Playwright, highlighting efficacy in test setup and teardown procedures. With the use of common programming principles and design patterns, test developers can create a bulletproof and scalable framework for any given project.

Raszka  Adrian

Adrian Raszka

QA Engineer

Adrian Raszka is a QA Engineer and author on CodiLime's blog. Check out the author's articles on the blog. Read about author >

Read also

Get your project estimate

For businesses that need support in their software or network engineering projects, please fill in the form and we’ll get back to you within one business day.

For businesses that need support in their software or network engineering projects, please fill in the form and we’ll get back to you within one business day.

We guarantee 100% privacy.

Trusted by leaders:

Cisco Systems
Palo Alto Services
Equinix
Jupiter Networks
Nutanix