Rayrun

Mastering Playwright: Best Practices for Web Automation with the Page Object Model

Learn the best practices for creating maintainable, reliable, and scalable test scripts using POM.
  1. The Anatomy of a Page Object
    1. Understanding Page Object Model Significance
      1. Identifying Properties
        1. Identifying Locators
        2. Identifying Actions
          1. Identifying Assertions
          2. Using Page Objects in Tests
            1. Using Fixtures to Create Page Objects
              1. Best Practices for Page Objects
                1. Separate Page Objects for Each Page
                  1. Separate Actions and Assertions
                    1. Avoid Extending Page Objects
                      1. Keep Page Objects Small
                        1. Do Not Use State in Page Objects
                        2. Principles of Good Page Objects
                          1. Advantages of the Page Object Model
                            1. Key Takeaways
                              1. Additional Resources

                                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:

                                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:

                                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:

                                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:

                                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:

                                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. 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.

                                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:

                                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:

                                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.

                                // ❌ 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.

                                // ❌ 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

                                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 to continue exploring and learning about other design patterns and best practices to enhance your test automation efforts. Happy testing!

                                Thank you!
                                Was this helpful?

                                Now check your email!

                                To complete the signup, click the confirmation link in your inbox.

                                Subscribe to newsletter

                                You will receive a weekly email with the latest posts and QA jobs.

                                TwitterGitHubLinkedIn
                                AboutQuestionsDiscord ForumBrowser ExtensionTagsQA Jobs

                                Rayrun is a community for QA engineers. I am constantly looking for new ways to add value to people learning Playwright and other browser automation frameworks. If you have feedback, email luc@ray.run.