Refactor Playwright Locators Like a Boss

Your first Playwright automations are likely to be a little... messy. After all, the web is a messy place. But taking automations to production means untangling that ball of spaghetti code, and transforming it into something more clear, performant, and maintainable.

This article explores a few great techniques for refactoring your Playwright locators. And every idea is really quick to implement. After all, there's no point in suggesting optimizations that make your developer life painful!

If you find this post helpful, check out my full Playwright locator deep-dive for even more ideas on optimizing your scripts for speed, scale, and stability.

Let's get started!

Extend Playwright with Custom Selectors

Playwright opens up a world of possibilities with its selector engine, not only by supporting a wide range of built-in selectors but also by empowering users to define their own.

Custom selectors can be tailored to unique application requirements, make scripts more readable, and support complex selection logic that goes beyond the basics.

Here are some ideas:

  1. data-state: Select elements based on a data attribute representing state (e.g., data-state=\"active\") to target specific UI states in a single-page application.

  2. closest: Select the closest ancestor of an element that matches a certain selector—akin to the Element.closest() method in JavaScript.

  3. shadow: Target elements that are within a specific shadow DOM.

  4. rotation: Select an element rotated within a particular range.

To demonstrate the potential of custom selectors in Playwright, let's create the data-state selector described above...

import * as pw from 'playwright';

// register custom selector
await pw.selectors.register('data-state', () => ({
  query(root: Node, selector: string) {
    return root.querySelector(`[data-state="${selector}"]`);
  },

  queryAll(root: Node, selector: string) {
    return Array.from(
      root.querySelectorAll(`[data-state="${selector}"]`)
    );
  },
}));

const browser = await pw.firefox.launch();
const page = await browser.newPage();
await page.goto('https://example.com');

// use the custom selector
const $active = page.locator('data-state=active');
await $active.click();

As you can see, it takes very little to create a custom selector. Since Playwright grants you access to the underlying DOM, the sky's the limit.

Use the Page Object Model

The Page Object Model (POM) is a design pattern widely used in automation for improving maintainability and reducing duplication. A page object encapsulates the behaviors and elements of a specific page in your target site, making it easy to update the code as needed.

Here's an example of what a page object might look like for a login page. Notice how it collects common locators as well as behaviors likely to be repeated in multiple different scripts...

import {Page} from 'playwright';

export class LoginPage {
  page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  get $usernameInput() {
    return this.page.getByLabel('Username');
  }

  get $passwordInput() {
    return this.page.getByLabel('Password');
  }

  get $loginButton() {
    return this.page.getByText('Login');
  }

  public async login(username: string, password: string) {
    await this.$usernameInput.fill(username);
    await this.$passwordInput.fill(password);
    await this.$loginButton.click();
  }
}

Here's how you might use the LoginPage POM:

import * as pw from 'playwright';
import {LoginPage} from './LoginPage';

const browser = await pw.webkit.launch();
const page = await browser.newPage();
await page.goto('https://app.browsercat.com/sign-in');

const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'p1a2s3s4word!');
await loginPage.$loginButton.click();

Compose Page Objects from Components

I've found you can extend the wisdom of the Page Object Model further. Consider that most websites are built from reusable components that are repeated on multiple pages. Rather than designing POMs from scratch, instead, you can compose them from components found on page after page.

Here's how we might leverage component objects for AppMenuBar, ChatWidget, and AppFooter:

// Define the separate components
class AppMenuBar {
  constructor(public page: Page) {}
  get $logo () {}
  get $menuButton () {}
}

class ChatWidget {
  constructor(public page: Page) {}
  get $chatToggle () {}
  get $messageInput () {}
  get $sendButton () {}

  async sendMessage(message: string) {}
}

class AppFooter {
  constructor(public page: Page) {}
  get $uptimeStatus () {}
}

// Compose these components into a complete page object
class ContactPage {
  menuBar: AppMenuBar;
  chatWidget: ChatWidget;
  footer: AppFooter;

  constructor(public page: Page) {
    this.menuBar = new AppMenuBar(page);
    this.chatWidget = new ChatWidget(page);
    this.footer = new AppFooter(page);
  }
}

// Use the composed POM
const browser = await pw.chromium.launch();
const page = await browser.newPage();
await page.goto('https://www.browsercat.com/contact');
const contactPage = new ContactPage(page);

// Use the component methods
await contactPage.menuBar.$menuButton.click();
await contactPage.chatWidget.sendMessage('Hello there!');

In the example above, the composed ContactPage object becomes a powerful and convenient abstraction, bundling the interactions for the menu bar, chat widget, and app footer, which are then used in repeated interactions. This approach keeps your scripts DRY (Don't Repeat Yourself) and makes it easy to update component interactions when the UI changes, without having to change each usage.

Next steps

As you can see, these techniques are quick to implement and can have a big impact on the readability, maintainability, and performance of your Playwright scripts. Give one or two a try on an existing project, and see how it goes!

Experimentation is the best way to learn.

If you're looking for more ideas on how to strengthen your Playwright scripts, check out my locator deep-dive next.

Happy automating!