Playwright·Cookbook Field Manual
Part 02 · Locators & Structure Card 12

Locators, Actions, Flows

Card 12: Locators → Actions → Flows (3-Layer Model)

What This Pattern Solves

As your test suite grows beyond a few simple tests, inline selectors and raw page.goto() calls spread across dozens of files. A button’s test ID changes and you’re hunting through 15 files. Someone adds a waitForTimeout(3000) because the page “feels slow” — now your suite takes 3 minutes longer. The 3-layer model gives you a scalable architecture: Locators centralize every selector, Actions encapsulate every interaction with a built-in done signal, and Flows compose actions into business-meaningful steps that return the next page object.

This is the default structure for any non-trivial Playwright suite — it keeps tests describing what the user does, not how the DOM is structured.

How It Works

  1. Locators layer (personPageLocators): A plain function that takes page and returns an object of Playwright locators. No clicks, no assertions, no awaits — just locator definitions.
  2. Actions layer (loadPersonPage): One interaction plus a “done signal” — navigate to a URL AND wait for the heading to become visible. Actions can import locators and compose multiple steps into a single atomic operation.
  3. Flows layer (PersonPage): A lightweight class that composes actions into business-level steps. Exposes assertLoaded() for readiness checks and a static open() that loads the page and returns the next page object — the return-next-page pattern.
  4. Tests import from the layer they need: use flows for full-page scenarios, use actions directly when you need finer control, and use locators for ad-hoc assertions.

Code Example

// ── e2e-patterns/person/locators.ts ───────────────────────
import type { Page } from '@playwright/test';

export function personPageLocators(page: Page) {
  return {
    heading: page.getByRole('heading', { name: 'Person' }),
    name: page.getByTestId('person-name'),
    height: page.getByTestId('person-height'),
    mass: page.getByTestId('person-mass'),
    // Semantic-first: the element has role="alert".
    error: page.getByRole('alert'),
    loading: page.getByText('Loading…'),
    // Semantic-first: <button>Edit</button> with an accessible name.
    editButton: page.getByRole('button', { name: 'Edit' }),
  };
}

// ── e2e-patterns/person/actions.ts ────────────────────────
import type { Page } from '@playwright/test';
import { personPageLocators } from './locators';

export async function loadPersonPage(
  page: Page,
  id: string,
  baseUrl: string = '/',
): Promise<void> {
  const url = baseUrl.includes('?')
    ? `${baseUrl}&id=${id}`
    : `${baseUrl}?id=${id}`;
  await page.goto(url);
  const $ = personPageLocators(page);
  await $.heading.waitFor({ state: 'visible', timeout: 10000 });
}

// ── e2e-patterns/person/PersonPage.ts ─────────────────────
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { loadPersonPage } from './actions';
import { personPageLocators } from './locators';

export class PersonPage {
  constructor(readonly page: Page) {}

  async assertLoaded(): Promise<void> {
    const $ = personPageLocators(this.page);
    await expect($.heading).toBeVisible();
  }

  static async open(
    page: Page,
    personId: string,
    baseUrl: string = '/',
  ): Promise<PersonPage> {
    await loadPersonPage(page, personId, baseUrl);
    const personPage = new PersonPage(page);
    await personPage.assertLoaded();
    return personPage;
  }
}

// ── Test ──────────────────────────────────────────────────
import { test, expect } from '@playwright/test';
import { PersonPage } from '../e2e-patterns/person/PersonPage';
import { personPageLocators } from '../e2e-patterns/person/locators';
import { loadPersonPage } from '../e2e-patterns/person/actions';

// Test using flows
test('flow returns PersonPage and assertLoaded passes', async ({ page }) => {
  const personPage = await PersonPage.open(page, '1', '/cards/12');
  await personPage.assertLoaded();
  const $ = personPageLocators(page);
  await expect($.name).toHaveText('Luke Skywalker');
});

// Test using actions + locators directly
test('actions and locators used without flows', async ({ page }) => {
  await loadPersonPage(page, '1', '/cards/12');
  const $ = personPageLocators(page);
  await expect($.heading).toBeVisible();
  await expect($.name).toHaveText('Luke Skywalker');
});

Run This Example

pnpm test src/12-locators-actions-flows

Prerequisites

Key Concepts

When to Use This Pattern

Common Mistakes

  1. Putting awaits in the locators function:

    // ✗ WRONG — locators with actions
    export function personPageLocators(page: Page) {
      return {
        heading: page.getByRole('heading', { name: 'Person' }),
        editButton: async () => await page.getByTestId('edit-person').click(),
      };
    }
    
    // ✓ CORRECT — locators return locators only
    export function personPageLocators(page: Page) {
      return {
        heading: page.getByRole('heading', { name: 'Person' }),
        editButton: page.getByRole('button', { name: 'Edit' }),
      };
    }
  2. Dumping everything into one giant page class:

    // ✗ WRONG — 200-line class with locators, actions, and assertions
    class PersonPage {
      heading = this.page.getByRole('heading', { name: 'Person' });
      async clickEdit() { /* ... */ }
      async fillName(name: string) { /* ... */ }
      async assertLoaded() { /* ... */ }
      async assertErrorVisible() { /* ... */ }
      // ... 30 more methods
    }
    
    // ✓ CORRECT — split into locators, actions, and a thin flow class
  3. Skipping ‘done signals’ in actions:

    // ✗ WRONG — action without completion signal
    export async function loadPersonPage(page: Page, id: string) {
      await page.goto(`/?id=${id}`);
      // caller must remember to waitFor heading — flaky!
    }
    
    // ✓ CORRECT — action includes its own done signal
    export async function loadPersonPage(page: Page, id: string) {
      await page.goto(`/?id=${id}`);
      await page.getByRole('heading', { name: 'Person' })
        .waitFor({ state: 'visible', timeout: 10000 });
    }
  4. Not re-exporting or re-using across layers:

    • Actions should import locators — don’t duplicate selectors
    • Flows should import actions — don’t re-implement navigation
    • This creates a dependency chain: tests → flows → actions → locators
  5. Making flows too smart: Flows should compose actions, not contain business logic. If you’re conditionally branching in a flow, you probably need a separate flow or a parameterized action.

Flow Diagram

sequenceDiagram
    participant Test
    participant PersonPage (Flow)
    participant loadPersonPage (Action)
    participant personPageLocators (Locators)
    participant Browser
    participant API Route

    Note over Test: 3-layer model<br/>Tests → Flows → Actions → Locators

    Test->>PersonPage (Flow): PersonPage.open(page, '1', '/cards/12')
    PersonPage (Flow)->>loadPersonPage (Action): loadPersonPage(page, '1', '/cards/12')
    loadPersonPage (Action)->>Browser: page.goto('/cards/12?id=1')
    Browser->>API Route: fetch /people/1
    API Route-->>Browser: 200 + Luke Skywalker
    loadPersonPage (Action)->>personPageLocators (Locators): get heading locator
    personPageLocators (Locators)-->>loadPersonPage (Action): heading locator
    loadPersonPage (Action)->>Browser: waitFor heading visible
    Browser-->>loadPersonPage (Action): ✓ heading visible
    loadPersonPage (Action)-->>PersonPage (Flow): done
    PersonPage (Flow)->>PersonPage (Flow): assertLoaded() — re-checks heading
    PersonPage (Flow)-->>Test: PersonPage instance
    Test->>personPageLocators (Locators): personPageLocators(page)
    personPageLocators (Locators)-->>Test: { heading, name, height, ... }
    Test->>Browser: expect($.name).toHaveText('Luke Skywalker')
    Browser-->>Test: ✓

Live Demo

👇 This component is what the Playwright test interacts with:

Loading…

Run This Example

pnpm test src/12-locators-actions-flows