notes.dt.in.th

How I structure Playwright page objects

After using Playwright for 4 years, I think I have finally found the code structure for creating page objects that I’m actually happy with. First, let's take a look at a before and after comparison:

Before

import { test, expect } from '@playwright/test'

// Multiple page objects have to be imported...
import { LoginPage } from './page-objects/LoginPage'
import { RepoPage } from './page-objects/RepoPage'
import { IssueCreatePage } from './page-objects/IssueCreatePage'
import { IssueViewPage } from './page-objects/IssueViewPage'
import { IssueListPage } from './page-objects/IssueListPage'

// ...and declared...
let loginPage: LoginPage
let repoPage: RepoPage
let issueCreatePage: IssueCreatePage
let issueViewPage: IssueViewPage
let issueListPage: IssueListPage

// ...and initialized in the beforeEach hook...
test.beforeEach(async ({ page }) => {
  loginPage = new LoginPage(page)
  repoPage = new RepoPage(page)
  issueCreatePage = new IssueCreatePage(page)
  issueViewPage = new IssueViewPage(page)
  issueListPage = new IssueListPage(page)
})

// ...so that they can be used in the test.
test('Create a new issue', async ({ page }) => {
  await loginPage.goto()
  await loginPage.login('username', 'password')

  await repoPage.goto('myorg/myrepo')
  await repoPage.navigateToIssues()
  await issueListPage.createNewIssue()
  await issueCreatePage.createIssue('New bug', 'This is a new bug')
  await issueViewPage.expectIssueTitle('New bug')
})

As you can see, there is more boilerplate code than the actual test logic.

After

import { test, expect } from '@playwright/test'

// A single App class is imported...
import { App } from './page-objects'

test('Create a new issue', async ({ page }) => {
  // ...and initialized in the test.
  const app = new App({ page })

  // The App provides access to all the page objects
  // which are lazily initialized.
  await app.loginPage.goto()
  await app.loginPage.login('username', 'password')
  await app.repoPage.goto('myorg/myrepo')
  await app.repoPage.navigateToIssues()
  await app.issueListPage.createNewIssue()
  await app.issueCreatePage.createIssue('New bug', 'This is a new bug')
  await app.issueViewPage.expectIssueTitle('New bug')
})

As you can see, the "After" version is much cleaner and more concise. We no longer need to import multiple page objects, declare variables, or initialize them in a beforeEach hook. Instead, we have a single App class that serves as an entry point to all our page objects.

How it works

Let's break down how this structure is achieved.

The PageObject superclass

First, we define a PageObject superclass and a PageObjectContext interface:

import { Page } from '@playwright/test'

export interface PageObjectContext {
  page: Page
}

export abstract class PageObject {
  constructor(protected readonly context: PageObjectContext) {}

  protected get page() {
    return this.context.page
  }
}

This superclass provides a common structure for all page objects and ensures they have access to the Playwright Page object.

The App class

The App class serves as the main entry point for all page objects:

import { Page } from '@playwright/test'
import { PageObject, PageObjectContext } from './PageObject'
import { LoginPage } from './LoginPage'
import { RepoPage } from './RepoPage'
import { IssueCreatePage } from './IssueCreatePage'
import { IssueViewPage } from './IssueViewPage'
import { IssueListPage } from './IssueListPage'

export class App extends PageObject {
  constructor(context: PageObjectContext | Page) {
    if ('page' in context) {
      super(context)
    } else {
      super({ page: context })
    }
  }
  get loginPage() {
    return new LoginPage(this.context)
  }
  get repoPage() {
    return new RepoPage(this.context)
  }
  get issueCreatePage() {
    return new IssueCreatePage(this.context)
  }
  get issueViewPage() {
    return new IssueViewPage(this.context)
  }
  get issueListPage() {
    return new IssueListPage(this.context)
  }
}

The App class uses getters to instantiate page objects on-demand, providing a unified API for testing.

Individual page objects

Each page object extends the PageObject class:

export class LoginPage extends PageObject {
  async goto() {
    await this.page.goto('https://github.com/login')
  }
  async login(username: string, password: string) {
    await this.page.fill('#login_field', username)
    await this.page.fill('#password', password)
    await this.page.click('input[type="submit"]')
  }
}

General notes

  • Only use when necessary: This structure is most useful when your app starts growing and you need to scale your test suite. For small projects, it might be overkill.

  • Keep page objects stateless: Page objects should not maintain any state. This allows them to be instantiated on-demand and discarded after each line.

  • Page groups: If multiple pages are closely related, consider grouping them under a single page object. e.g.

    // Note that `Settings` do not end with `Page` to indicate
    // that it’s merely grouping multiple page objects together.
    class Settings extends PageObject {
      get account() {
        return new SettingsAccountPage(this.context)
      }
      get profile() {
        return new SettingsProfilePage(this.context)
      }
      get security() {
        return new SettingsSecurityPage(this.context)
      }
    }
  • Sub-components: If a page has multiple distinct sections, consider using getters to provide access to these as separate page objects.

    • If these sub-components are used in just one page, they can be put in the same file as the main page object.

    • If some sub-components are used in multiple pages, they can be put in their own file so they can be shared across multiple page objects.

  • Working with multiple tabs: Sometimes some actions open a new tab. This causes a new Page object to be created. You can create a pageObjectContext object separately so that you can mutate the page property during the test.

    const pageObjectContext = { page }
    const app = new App(pageObjectContext)
    
    // Prepare for a new tab
    const nextPagePromise = this.page.context().waitForEvent('page')
    
    // Do something here that causes a new tab to open
    // ...
    
    // Then switch to the new tab
    pageObjectContext.page = await nextPagePromise