Mastering Playwright: Best Practices for Web Automation with the Page Object Model
Tags: playwright, tutorial, learning, javascript
Published: 2023-10-02 23:48:50

Originally published on https://ray.run/blog/mastering-poms.

In the realm of web automation, integrating Playwright with the Page Object Model (POM) can turbocharge your testing strategy. By following best practices, you can achieve maintainable, reliable, and scalable test scripts. Let's dive deep into how POM elevates Playwright's capabilities for all you QA engineers out there.

## The Anatomy of a Page Object

A Page Object encapsulates all the interactions and elements of a particular web page (or a segment of it) within a class. This separation brings about three primary constituents:

1. **Element Selectors**: These are the definitions that point to specific elements on the web page.
2. **Methods**: Functions that encapsulate one or more interactions with the web elements.
3. **Properties**: Any additional information or attributes related to the page, such as its URL.

In the heart of POM lies the principle of abstraction. You're not merely scripting tests; you're crafting an intuitive interface to your application's UI.

## Understanding Page Object Model Significance

Using POMs ensures an organized approach to test script creation. The core idea is **abstracting** the UI interactions and elements into easily manageable objects. This abstraction ensures that changes in the UI only necessitate updates in one place, making your test scripts resilient to frequent application updates.

For example, consider logging into a web application on `https://ray.run/`. Instead of writing raw Playwright commands in every test, with POM you'll encapsulate them within a `SignInPage` class.

### Identifying Properties

Imagine you're dealing with a login form on `https://ray.run/signin`. First, identify the properties of the page:

```typescript
import { type Page } from '@playwright/test';

class SignInPage {
  readonly page: Page;
  readonly url: string = 'https://ray.run/signin';

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

### Identifying Locators

Then, pinpoint the elements:

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

class SignInPage {
  readonly page: Page;
  readonly url: string = 'https://ray.run/signin';
  readonly emailInputLocator: Locator;
  readonly passwordInputLocator: Locator;
  readonly signinButtonLocator: Locator;

  public constructor(page: Page) {
    this.page = page;
    page.emailInputLocator = page.getByLabel('Email');
    page.passwordInputLocator = page.getByLabel('Password');
    page.signInButtonLocator = page.getByRole('button', { name: 'Sign In' })
  }
}
```

## Identifying Actions

Then, identify the actions:

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

class SignInPage {
  readonly page: Page;
  readonly url: string = 'https://ray.run/signin';
  readonly emailInputLocator: Locator;
  readonly passwordInputLocator: Locator;
  readonly signinButtonLocator: Locator;

  public constructor(page: Page) {
    this.page = page;
    page.emailInputLocator = page.getByLabel('Email');
    page.passwordInputLocator = page.getByLabel('Password');
    page.signInButtonLocator = page.getByRole('button', { name: 'Sign In' })
  }

  async visit() {
    await this.page.goto(this.url);
  }

  async login(email: string, password: string) {
    await this.emailInputLocator.fill(email);
    await this.passwordInputLocator.fill(password);
    await this.signInButtonLocator.click();
  }
}
```

### Identifying Assertions

Then, identify the assertions:

```typescript
import { type Page, type Locator, expect } from '@playwright/test';

class SignInPage {
  readonly page: Page;
  readonly url: string = 'https://ray.run/signin';
  readonly emailInputLocator: Locator;
  readonly passwordInputLocator: Locator;
  readonly signinButtonLocator: Locator;

  public constructor(page: Page) {
    this.page = page;
    page.emailInputLocator = page.getByLabel('Email');
    page.passwordInputLocator = page.getByLabel('Password');
    page.signInButtonLocator = page.getByRole('button', { name: 'Sign In' })
  }

  async visit() {
    await this.page.goto(this.url);
  }

  async login(email: string, password: string) {
    await this.emailInputLocator.fill(email);
    await this.passwordInputLocator.fill(password);
    await this.signInButtonLocator.click();
  }

  async isSignedIn() {
    await expect(this.page.getByTestId('status')).toHaveText('Signed In');
  }
}
```

This structure ensures that any UI change only mandates an update within our `SignInPage` class, not across multiple test scripts.

Notice how we've added every abstraction layer one by one. This is similar to how you'd adopt POMs in a real-world scenario. Also, the level of abstraction that you choose is entirely up to you.

## Using Page Objects in Tests

With the page objects in place, we can now write tests that utilize the encapsulated functionality. Here's an example test using the previously defined page objects:

```typescript
import { test, expect } from '@playwright/test';
import { SignInPage } from './SignInPage';

test('user signs in', async ({ page }) => {
  const signInPage = new SignInPage(page);
  await signInPage.visit();
  await signInPage.login('foo@ray.run', 'bar');
  await signInPage.isSignedIn();
});
```

Isn't this neat? We've abstracted away the low-level details of interacting with the page, such as finding the email and password fields and clicking the submit button. This makes the test code more readable and focused on the high-level behavior of the page.

## Using Fixtures to Create Page Objects

A big part of the Playwright test framework is the concept of [fixtures](https://playwright.dev/docs/test-fixtures). Fixtures are used to set up the test environment and provide access to the browser and page objects. They can also be used to create page objects that can be used across multiple tests.

Let's see how we can use fixtures to create page objects.

```typescript
import { test as base } from '@playwright/test';
import { SignInPage } from './SignInPage';

export const test = base.extend<{ signInPage: SignInPage }>({
  signInPage: async ({ page }, use) => {
    const signInPage = new SignInPage(page);
    await use(signInPage);
  },
});
```

Now we can use the `signInPage` fixture in our tests to access the page object:

```typescript
import { test, expect } from '@playwright/test';
import { SignInPage } from './SignInPage';

test('user signs in', async ({ signInPage }) => {
  await signInPage.visit();
  await signInPage.login('foo@ray.run', 'bar');
  await signInPage.isSignedIn();
});
```

## Best Practices for Page Objects

Now that you've got a good understanding of the Page Object Model and how it can be implemented in Playwright, let's take a look at some best practices for creating page objects.

### Separate Page Objects for Each Page

Each page of your application should have its own page object. This ensures that the code remains organized and maintainable, with clear boundaries between different areas of functionality.

### Separate Actions and Assertions

It's a good practice to separate the actions and assertions in your page objects. This makes it easier to understand the flow of the test and ensures that the page object is reusable across different tests.

For example, you might be tempted to write the `login` method like this:

```typescript
public async login(email: string, password: string) {
  await this.emailInputLocator.fill(email);
  await this.passwordInputLocator.fill(password);
  await this.signInButtonLocator.click();
  await expect(this.page.getByTestId('status')).toHaveText('Signed In');
}
```

However, putting the assertion in the `login` method makes it less reusable.

### Avoid Extending Page Objects

It's a good practice to avoid extending page objects. This can lead to a bloated page object with too many methods and properties, making it difficult to maintain and understand.

```ts
// ❌ Don't do this
class AuthenticationPage {}
class SignInPage extends AuthenticationPage {}
class SignupPage extends AuthenticationPage {}
```

As a general rule, if you find yourself extending a POM class, it's a sign that you need to refactor your code. And if you are finding yourself duplicating code between POMs, then consider if you need to introduce another abstraction layer.

### Keep Page Objects Small

It's a good practice to keep your page objects small and focused on a single page or a small section of a page. This ensures that the code remains organized and maintainable, with clear boundaries between different areas of functionality.

In general, avoid adding actions/assertions that are not re-used across multiple tests.

### Do Not Use State in Page Objects

It's a good practice to avoid using state in your page objects. This ensures that the page object is reusable across different tests and makes it easier to understand the flow of the test.

```ts
// ❌ Don't do this
import { type Page, expect } from '@playwright/test';

class SignInPage {
  authenticated: boolean;
  
  // ...

  async isSignedIn() {
    await expect(this.page.getByTestId('status')).toHaveText('Signed In');

    this.authenticated = true;
  }
}
```

## Principles of Good Page Objects

When implementing the Page Object Model with Playwright, it's essential to adhere to the following principles:

1. **Single Responsibility Principle (SRP):** Each page object should be responsible for a single page or a small section of a page. This ensures that the code remains organized and maintainable, with clear boundaries between different areas of functionality.
2. **Abstraction:** Page objects should abstract away the details of interacting with the page, such as locators and methods for interacting with elements. By providing a more readable and intuitive interface, abstraction reduces the brittleness of the test code and improves its maintainability.
3. **Encapsulation:** Page objects should encapsulate the state and behavior of the page, making it easy to reason about the page's current state and the actions that can be performed on it. This helps in maintaining a clear separation of concerns and improves the overall readability of the tests.
4. **Reusability:** Page objects should be designed to be reusable across different tests, reducing code duplication and improving the efficiency of test development. By creating modular and self-contained page objects, you can easily compose tests from reusable building blocks.
5. **Easy to Understand:** The naming of methods and variables in page objects should be self-explanatory, making it easy for anyone to understand the purpose and functionality of the code. Clear and descriptive names enhance the readability and maintainability of the tests.
6. **Separation of Concerns:** The test code should focus on the high-level behavior of the page, while the page objects should handle the low-level details of interacting with the page. This separation allows for better code organization and promotes a more maintainable and scalable test suite.

## Advantages of the Page Object Model

POMs offer distinct advantages for you as a QA engineer:

- **Reusability**: Common page interactions are defined once and used across multiple test scripts.
- **Readability**: Tests become self-explanatory. Colleagues can understand the flow without diving into the UI details.
- **Maintainability**: UI changes? No problem. Update the respective page object, and you're good to go!

## Key Takeaways

* The Page Object Model (POM) is a design pattern that abstracts page specific properties, locators, actions, and assertions.
* Implementing POM in Playwright involves creating separate classes for each page, implementing methods for user actions, and using these page objects in your tests.
* Avoid common pitfalls such as extending page objects, mixing actions and assertions, and creating bloated page objects.

## Additional Resources

* [Playwright Official POM Documentation](https://playwright.dev/docs/pom)

Remember, the implementation of the Page Object Model in Playwright is just one part of a comprehensive test automation strategy. Read the other articles in [Rayrun blog](https://ray.run/blog) to continue exploring and learning about other design patterns and best practices to enhance your test automation efforts. Happy testing!