A page object keeps one screen’s locators and actions in one place, so your tests call its methods instead of repeating the same queries. It’s a design pattern, and it works the same in any test framework. If you’ve written page objects in Playwright or Selenium, this will look familiar.

In e2e, a page object is a plain TypeScript class that takes screen. You hand it to tests through a fixture.

Write a page object

The constructor takes screen and builds the locators. Locators are lazy, so nothing is looked up until a method uses one:

tests/pages/sign-in-page.ts
import type { Locator, Screen, Secret } from 'e2e';

export class SignInPage {
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(readonly screen: Screen) {
    this.email = screen.getByLabel('Email');
    this.password = screen.getByLabel('Password');
    this.submit = screen.getByRole('button', 'Sign in');
    this.error = screen.getByRole('alert');
  }

  async signIn(email: string, password: string | Secret) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.tap();
  }
}

Don’t end the file name in .e2e.ts, or the runner collects it as a test.

Hand it out as a fixture

Create the page object in a fixture with test.extend. Every test that imports this test gets it, with no beforeEach:

tests/fixtures.ts
import { test as base } from 'e2e';
import { SignInPage } from './pages/sign-in-page.ts';

export const test = base.extend<{ signInPage: SignInPage }>({
  signInPage: async ({ screen }, use) => {
    await use(new SignInPage(screen));
  },
});
tests/sign-in.e2e.ts
import { expect } from 'e2e';
import { test } from './fixtures.ts';

test('rejects a wrong password', async ({ app, signInPage }) => {
  await app.open();
  await signInPage.signIn('ada@example.test', 'not-the-password');
  await expect(signInPage.error).toHaveText('Wrong email or password');
});

Keep it portable

A page object that uses only screen runs on web and mobile targets alike. When it needs browser (keyboard, CSS selectors, evaluate) or device, it runs on that platform only.

A fixture that reads browser or device makes every test on that test need it. On another target, those tests fail in beforeEach with UNSUPPORTED_CAPABILITY. Give those tests requires: ['browser'] to skip them there instead, or keep portable tests on the base test.

Where agent goals fit

A method can take agent as an argument and wrap a goal the app changes often:

async addCard(agent: Agent, name: string) {
  await agent.act('add a new card named {name}', { params: { name } });
}

Don’t read agent in the fixture. Every test on that test would then need a configured model, the deterministic ones too.

Many page object methods exist only to click through a flow. One agent.act goal can replace them. Keep methods for the steps every test repeats and needs exact.

To start tests signed in, don’t call signIn in each one. Save a session one time instead. See Signing in.

Next