Playwright Lesson 3 of 6 ⏱️ 5 min read

Page Objects & Fixtures

Page object classes, custom fixtures and reusing a logged-in state with storageState.

Updated Oct 30, 2026

Page objects in Playwright

The Page Object Model works naturally in Playwright: a class holds the page's locators and actions, and tests describe behaviour.

Typescript
// pages/TodoPage.ts
import { type Locator, type Page, expect } from '@playwright/test';

export class TodoPage {
  readonly newTodo: Locator;
  readonly items: Locator;
  readonly count: Locator;

  constructor(readonly page: Page) {
    this.newTodo = page.getByPlaceholder('What needs to be done?');
    this.items = page.getByTestId('todo-item');
    this.count = page.getByTestId('todo-count');
  }

  async goto() {
    await this.page.goto('https://demo.playwright.dev/todomvc');
  }

  async add(...titles: string[]) {
    for (const title of titles) {
      await this.newTodo.fill(title);
      await this.newTodo.press('Enter');
    }
  }

  item(title: string): Locator {
    return this.items.filter({ hasText: title });
  }

  async complete(title: string) {
    await this.item(title).getByRole('checkbox').check();
  }

  async remove(title: string) {
    await this.item(title).hover();
    await this.item(title).getByRole('button', { name: 'Delete' }).click();
  }

  async expectRemaining(n: number) {
    await expect(this.count).toContainText(`${n} item${n === 1 ? '' : 's'} left`);
  }
}
Typescript
// tests/todo-pom.spec.ts
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/TodoPage';

test('tracks remaining items', async ({ page }) => {
  const todos = new TodoPage(page);
  await todos.goto();
  await todos.add('Write tests', 'Fix bug', 'Deploy');

  await todos.complete('Fix bug');

  await todos.expectRemaining(2);
  await expect(todos.item('Fix bug')).toHaveClass(/completed/);
});

test('deletes an item', async ({ page }) => {
  const todos = new TodoPage(page);
  await todos.goto();
  await todos.add('Temporary task');

  await todos.remove('Temporary task');

  await expect(todos.items).toHaveCount(0);
});

Locators are created once in the constructor; because locators are lazy, they always find the current element.

Fixtures: Playwright's superpower

Fixtures provide everything a test needs — and clean it up afterwards. You've already used built-in ones: page, context, browser, request, browserName. You can define your own with test.extend, so tests receive ready-made page objects:

Typescript
// fixtures.ts
import { test as base } from '@playwright/test';
import { TodoPage } from './pages/TodoPage';

type Fixtures = {
  todoPage: TodoPage;
};

export const test = base.extend<Fixtures>({
  todoPage: async ({ page }, use) => {
    const todoPage = new TodoPage(page);
    await todoPage.goto();
    await todoPage.add('Seeded item');   // setup
    await use(todoPage);                  // the test runs here
    // teardown (if needed) goes after use()
  },
});

export { expect } from '@playwright/test';
Typescript
// tests/todo-fixture.spec.ts
import { test, expect } from '../fixtures';

test('starts with one seeded item', async ({ todoPage }) => {
  await expect(todoPage.items).toHaveCount(1);
});

test('can add a second item', async ({ todoPage }) => {
  await todoPage.add('Another item');
  await todoPage.expectRemaining(2);
});

Fixtures are created only when a test asks for them, and each test gets its own copy — no shared state.

Reusing a logged-in state with storageState

Logging in through the UI before every test is slow. Instead, log in once in a setup project, save cookies and local storage to a file, and load that file in every test.

Typescript
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/login');
  await page.getByLabel('Username').fill('tomsmith');
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD ?? 'SuperSecretPassword!');
  await page.getByRole('button', { name: 'Login' }).click();
  await expect(page).toHaveURL(/\/secure$/);

  await page.context().storageState({ path: authFile });
});
Typescript
// playwright.config.ts (projects section)
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
],
Typescript
// tests/secure.spec.ts — already logged in
import { test, expect } from '@playwright/test';

test('secure area is available without logging in again', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/secure');
  await expect(page.getByRole('heading', { name: 'Secure Area' })).toBeVisible();
});

Add playwright/.auth to .gitignore — it contains session cookies.

Hooks and grouping

Typescript
test.describe('Cart', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/shop');
  });

  test('adds an item', async ({ page }) => { /* ... */ });

  test.skip('coupon stacking', async () => {});        // skip with a reason in a ticket
  test.fixme('broken on Safari', async () => {});       // known issue, not run
});

Prefer fixtures over beforeEach when setup is shared across files. Next: API Testing & Network Mocking.

Finished this lesson?
Tick it off to track your Playwright progress.
Next lesson