DEV Community

Kunal Singh
Kunal Singh

Posted on

Playwright Test Frameworks and Design Patterns: What to Use, When, and How (With TypeScript Examples)

POM, data-driven, keyword-driven, BDD and hybrid frameworks in Playwright + TypeScript, with the design patterns behind each and working code.

Most automation suites don't fail because of the tool. They fail because of structure: duplicated locators, hard-coded data, waitForTimeout() sprinkled everywhere, and setup code copy-pasted into every spec.

Playwright gives you a great runner, auto-waiting locators and fixtures out of the box. Design patterns give you the structure on top. In this post we'll walk through five framework architectures, the patterns that belong in each, and real Playwright + TypeScript code you can adapt.

What's inside

  1. The foundation: config, projects and fixtures
  2. Page Object Model (POM) framework
  3. Data-driven framework
  4. Keyword-driven framework
  5. BDD framework
  6. Hybrid framework
  7. Pattern cheat sheet and when to use what
  8. POM best practices and pitfalls in Playwright

The foundation: config, projects and fixtures

In Selenium-style frameworks you'd hand-write a driver factory and a thread-local singleton. Playwright already gives you both:

  • Projects are a built-in Factory: one config creates Chromium, Firefox, WebKit or mobile contexts.
  • Fixtures are built-in Dependency Injection: each test declares what it needs and Playwright creates, shares and tears it down.
  • Workers give each parallel process its own browser, so there's no shared state to guard.

Config as a Singleton

ES modules are evaluated once and cached, so a frozen config object is a natural Singleton:

// config/env.ts
export const env = Object.freeze({
  baseURL: process.env.BASE_URL ?? 'https://staging.example.com',
  adminUser: process.env.ADMIN_USER ?? 'admin',
  adminPass: process.env.ADMIN_PASS ?? 'secret',
  apiURL: process.env.API_URL ?? 'https://staging.example.com/api',
});
Enter fullscreen mode Exit fullscreen mode

Projects as a Factory

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { env } from './config/env';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: [['html'], ['list']],
  use: {
    baseURL: env.baseURL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    testIdAttribute: 'data-testid',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile',   use: { ...devices['Pixel 7'] } },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Run one browser with npx playwright test --project=firefox. Tests never know which browser they got.


1. Page Object Model (POM) framework

Idea: every screen becomes a class that owns its locators and the actions a user can perform there. Tests speak in business language, and a UI change is fixed in one place.

Patterns used: Page Object, Factory (a page manager), Dependency Injection (fixtures), Fluent interface (methods return the next page).

Use it when: you're automating any UI. It's the default starting point for almost every team.

A thin base page

Playwright locators auto-wait, so the base page stays small. It only holds what every page genuinely shares:

// pages/BasePage.ts
import { type Page } from '@playwright/test';

export abstract class BasePage {
  constructor(protected readonly page: Page) {}

  abstract readonly path: string;

  async goto(): Promise<this> {
    await this.page.goto(this.path);
    return this;
  }
}
Enter fullscreen mode Exit fullscreen mode

Page objects

Locators are defined once, with user-facing selectors (getByRole, getByLabel, getByTestId) instead of brittle CSS/XPath:

// pages/LoginPage.ts
import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { HomePage } from './HomePage';

export class LoginPage extends BasePage {
  readonly path = '/login';
  readonly username: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(page: Page) {
    super(page);
    this.username = page.getByLabel('Username');
    this.password = page.getByLabel('Password');
    this.submit   = page.getByRole('button', { name: 'Sign in' });
    this.error    = page.getByTestId('login-error');
  }

  async loginAs(user: string, pass: string): Promise<HomePage> {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
    return new HomePage(this.page);
  }

  async loginExpectingError(user: string, pass: string): Promise<this> {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
    return this;
  }
}
Enter fullscreen mode Exit fullscreen mode
// pages/HomePage.ts
import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { HeaderComponent } from '../components/HeaderComponent';
import { ProductPage } from './ProductPage';

export class HomePage extends BasePage {
  readonly path = '/';
  readonly welcome: Locator;
  readonly header: HeaderComponent;

  constructor(page: Page) {
    super(page);
    this.welcome = page.getByTestId('welcome-banner');
    this.header  = new HeaderComponent(page);
  }

  async openProduct(name: string): Promise<ProductPage> {
    await this.page.getByRole('link', { name }).click();
    return new ProductPage(this.page);
  }
}
Enter fullscreen mode Exit fullscreen mode

Component objects

Repeated UI blocks become components, composed into pages rather than inherited. Scoping them to a root locator keeps selectors short and unambiguous:

// components/HeaderComponent.ts
import { type Locator, type Page } from '@playwright/test';

export class HeaderComponent {
  readonly root: Locator;
  readonly search: Locator;
  readonly cartCount: Locator;

  constructor(page: Page) {
    this.root      = page.getByRole('banner');
    this.search    = this.root.getByRole('searchbox');
    this.cartCount = this.root.getByTestId('cart-count');
  }

  async searchFor(term: string): Promise<void> {
    await this.search.fill(term);
    await this.search.press('Enter');
  }
}
Enter fullscreen mode Exit fullscreen mode

Fixtures: inject pages instead of new-ing them

A custom fixture file extends Playwright's test. This is Dependency Injection: tests ask for loginPage, Playwright builds it.

// fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { HomePage } from '../pages/HomePage';

type Pages = {
  loginPage: LoginPage;
  homePage: HomePage;
};

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await use(loginPage);
  },
  homePage: async ({ page }, use) => {
    await use(new HomePage(page));
  },
});

export { expect } from '@playwright/test';
Enter fullscreen mode Exit fullscreen mode

The test

Playwright's web-first assertions retry automatically, so pages expose Locators and tests assert on them:

// tests/login.spec.ts
import { test, expect } from '../fixtures';

test.describe('Login', () => {
  test('valid user can log in', async ({ loginPage }) => {
    const home = await loginPage.loginAs('admin', 'secret');
    await expect(home.welcome).toBeVisible();
  });

  test('wrong password shows an error', async ({ loginPage }) => {
    await loginPage.loginExpectingError('admin', 'wrong');
    await expect(loginPage.error).toHaveText('Invalid username or password');
  });
});
Enter fullscreen mode Exit fullscreen mode

Optional: a page manager (Factory)

With 30+ pages, fixtures for each get noisy. A small Factory gives one entry point:

// pages/App.ts
import { type Page } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { HomePage } from './HomePage';
import { CartPage } from './CartPage';

export class App {
  constructor(private readonly page: Page) {}

  get login() { return new LoginPage(this.page); }
  get home()  { return new HomePage(this.page); }
  get cart()  { return new CartPage(this.page); }
}

// fixtures: app: async ({ page }, use) => use(new App(page)),
// test:     await app.login.goto(); await app.login.loginAs('admin', 'secret');
Enter fullscreen mode Exit fullscreen mode

2. Data-driven framework

Idea: one test, many data sets. Data lives outside the spec (JSON, CSV, a database or API), and Playwright generates one test per row.

Patterns used: Factory (pick a reader by file type), Builder (readable test objects), Strategy (swap the data source per environment).

Use it when: the flow is fixed but inputs vary: login rules, form validation, pricing combinations.

The simplest version: a typed array

There's no special API. Loop and call test() for each case, and give every test a unique title:

// tests/login-data.spec.ts
import { test, expect } from '../fixtures';

type LoginCase = { title: string; user: string; pass: string; error: string };

const cases: LoginCase[] = [
  { title: 'wrong password',  user: 'admin',  pass: 'wrong',  error: 'Invalid username or password' },
  { title: 'empty username',  user: '',       pass: 'secret', error: 'Username is required' },
  { title: 'locked account',  user: 'locked', pass: 'secret', error: 'Your account is locked' },
];

test.describe('Login validation', () => {
  for (const c of cases) {
    test(`shows error for ${c.title}`, async ({ loginPage }) => {
      await loginPage.loginExpectingError(c.user, c.pass);
      await expect(loginPage.error).toHaveText(c.error);
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

External files with a Factory

When data moves to files, a Factory hides which reader is used:

// data/readers.ts
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'csv-parse/sync';   // npm i -D csv-parse

export type Row = Record<string, string>;

export interface DataReader {
  read(file: string): Row[];
}

class JsonReader implements DataReader {
  read(file: string): Row[] {
    return JSON.parse(fs.readFileSync(file, 'utf-8'));
  }
}

class CsvReader implements DataReader {
  read(file: string): Row[] {
    return parse(fs.readFileSync(file), { columns: true, skip_empty_lines: true });
  }
}

export function readerFor(file: string): DataReader {
  switch (path.extname(file).toLowerCase()) {
    case '.json': return new JsonReader();
    case '.csv':  return new CsvReader();
    default: throw new Error(`No data reader for ${file}`);
  }
}

export function loadData(file: string): Row[] {
  return readerFor(file).read(file);
}
Enter fullscreen mode Exit fullscreen mode

data/login-errors.csv:

title,user,pass,error
wrong password,admin,wrong,Invalid username or password
empty username,,secret,Username is required
locked account,locked,secret,Your account is locked
Enter fullscreen mode Exit fullscreen mode
// tests/login-csv.spec.ts
import { test, expect } from '../fixtures';
import { loadData } from '../data/readers';

const rows = loadData('data/login-errors.csv');

for (const row of rows) {
  test(`login error: ${row.title}`, async ({ loginPage }) => {
    await loginPage.loginExpectingError(row.user, row.pass);
    await expect(loginPage.error).toHaveText(row.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

Data is read when the spec file loads, so Playwright knows every test up front and can run them in parallel.

Builder for complex test data

When a test needs a user with a dozen fields, a Builder gives sensible defaults and lets each test override only what matters:

// data/UserBuilder.ts
export interface User {
  firstName: string;
  lastName: string;
  email: string;
  role: 'customer' | 'admin';
  country: string;
}

export class UserBuilder {
  private user: User = {
    firstName: 'Test',
    lastName: 'User',
    email: `user.${Date.now()}.${Math.floor(Math.random() * 1e6)}@example.com`,
    role: 'customer',
    country: 'IN',
  };

  static aUser(): UserBuilder { return new UserBuilder(); }

  withRole(role: User['role']): this { this.user.role = role; return this; }
  withCountry(country: string): this  { this.user.country = country; return this; }
  withEmail(email: string): this      { this.user.email = email; return this; }

  build(): User { return { ...this.user }; }
}

// In a test: only the relevant detail is visible
const admin  = UserBuilder.aUser().withRole('admin').build();
const usUser = UserBuilder.aUser().withCountry('US').build();
Enter fullscreen mode Exit fullscreen mode

Combine it with Playwright's request fixture to create data through the API instead of the UI, which is much faster:

test('admin sees the users table', async ({ request, page }) => {
  const admin = UserBuilder.aUser().withRole('admin').build();
  const res = await request.post('/api/users', { data: admin });
  expect(res.ok()).toBeTruthy();

  await page.goto('/admin/users');
  await expect(page.getByRole('cell', { name: admin.email })).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

3. Keyword-driven framework

Idea: tests are rows of keywords (Open, Fill, Click, ExpectText) in a JSON or spreadsheet file. An engine maps each keyword to code, so non-programmers can write and maintain tests.

Patterns used: Command (each keyword is a function object), Registry/Strategy (look up keywords by name), Template Method (the same run, log and report flow for every step).

Use it when: manual testers or BAs own test cases and engineers own the keyword library.

The test case as data

// keywords/cases/login.json
{
  "name": "Admin can log in",
  "steps": [
    { "keyword": "Open",       "value": "/login" },
    { "keyword": "Fill",       "target": "label=Username", "value": "admin" },
    { "keyword": "Fill",       "target": "label=Password", "value": "secret" },
    { "keyword": "Click",      "target": "role=button:Sign in" },
    { "keyword": "ExpectText", "target": "testid=welcome-banner", "value": "Welcome, admin" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Resolving targets to locators

// keywords/locators.ts
import { type Locator, type Page } from '@playwright/test';

export function resolve(page: Page, target: string): Locator {
  const [type, ...rest] = target.split('=');
  const value = rest.join('=');

  switch (type) {
    case 'label':  return page.getByLabel(value);
    case 'testid': return page.getByTestId(value);
    case 'text':   return page.getByText(value);
    case 'css':    return page.locator(value);
    case 'role': {
      const [role, name] = value.split(':');
      return page.getByRole(role as Parameters<Page['getByRole']>[0], name ? { name } : undefined);
    }
    default: throw new Error(`Unknown target type in "${target}"`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Keywords as Commands, in a registry

// keywords/registry.ts
import { expect, type Page } from '@playwright/test';
import { resolve } from './locators';

export type Step = { keyword: string; target?: string; value?: string };
export type Keyword = (page: Page, step: Step) => Promise<void>;

export const keywords: Record<string, Keyword> = {
  Open:       async (page, s) => { await page.goto(s.value!); },
  Fill:       async (page, s) => { await resolve(page, s.target!).fill(s.value ?? ''); },
  Click:      async (page, s) => { await resolve(page, s.target!).click(); },
  Press:      async (page, s) => { await resolve(page, s.target!).press(s.value!); },
  ExpectText: async (page, s) => { await expect(resolve(page, s.target!)).toContainText(s.value!); },
  ExpectUrl:  async (page, s) => { await expect(page).toHaveURL(new RegExp(s.value!)); },
};
Enter fullscreen mode Exit fullscreen mode

Adding a keyword means adding one line.

The engine

Wrapping each keyword in test.step() means the HTML report and trace viewer show every row as a named step:

// keywords/engine.ts
import { test, type Page } from '@playwright/test';
import { keywords, type Step } from './registry';

export async function runSteps(page: Page, steps: Step[]): Promise<void> {
  for (const [i, step] of steps.entries()) {
    const keyword = keywords[step.keyword];
    if (!keyword) throw new Error(`Unknown keyword "${step.keyword}" at step ${i + 1}`);

    const label = `${i + 1}. ${step.keyword} ${step.target ?? ''} ${step.value ?? ''}`.trim();
    await test.step(label, () => keyword(page, step));
  }
}
Enter fullscreen mode Exit fullscreen mode

One spec runs every case file

// tests/keyword-driven.spec.ts
import fs from 'node:fs';
import path from 'node:path';
import { test } from '@playwright/test';
import { runSteps } from '../keywords/engine';

const dir = 'keywords/cases';

for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) {
  const testCase = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));

  test(testCase.name, async ({ page }) => {
    await runSteps(page, testCase.steps);
  });
}
Enter fullscreen mode Exit fullscreen mode

A tester drops a new JSON file in keywords/cases, and a new test appears. (If you'd rather not build this yourself, Robot Framework with its Browser library is keyword-driven Playwright out of the box.)


4. BDD framework

Idea: behaviour is written in plain-language Gherkin, shared with product owners, and glued to code by step definitions.

Patterns used: Dependency Injection (fixtures inside steps), Page Objects or Screenplay (the automation layer), Facade (one step = one business action).

Use it when: business stakeholders actually read and review the scenarios. If only engineers read the feature files, BDD adds a layer without adding value.

There are two popular routes: Cucumber.js with Playwright as a library, or playwright-bdd, which turns feature files into Playwright tests so you keep fixtures, parallelism, traces and the HTML report. The example below uses playwright-bdd (check its docs for your version, as the config API has changed between releases).

The feature file

# features/checkout.feature
Feature: Checkout

  Background:
    Given I am logged in as "standard_user"

  Scenario: Buy a single product
    When I add "Backpack" to the cart
    And I check out with a valid address
    Then I should see the order confirmation

  Scenario Outline: Cart count updates
    When I add "<product>" to the cart
    Then the cart should show <count> item

    Examples:
      | product    | count |
      | Backpack   | 1     |
      | Bike Light | 1     |
Enter fullscreen mode Exit fullscreen mode

Config

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { defineBddConfig } from 'playwright-bdd';

const testDir = defineBddConfig({
  features: 'features/**/*.feature',
  steps: ['features/steps/**/*.ts', 'fixtures/index.ts'],
});

export default defineConfig({
  testDir,
  use: { baseURL: 'https://staging.example.com', screenshot: 'only-on-failure' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
Enter fullscreen mode Exit fullscreen mode

Run with npx bddgen && npx playwright test.

Step definitions use the same fixtures as normal tests

Steps receive fixtures as the first argument, so pages are injected exactly like in the POM section. No global state, no hand-built "world" object:

// features/steps/checkout.steps.ts
import { createBdd } from 'playwright-bdd';
import { test, expect } from '../../fixtures';
import { CheckoutFlow } from '../../flows/CheckoutFlow';
import { AddressBuilder } from '../../data/AddressBuilder';

const { Given, When, Then } = createBdd(test);

Given('I am logged in as {string}', async ({ loginPage }, user: string) => {
  await loginPage.loginAs(user, 'secret');
});

When('I add {string} to the cart', async ({ homePage }, product: string) => {
  const productPage = await homePage.openProduct(product);
  await productPage.addToCart();
});

When('I check out with a valid address', async ({ page }) => {
  await new CheckoutFlow(page).checkoutWith(AddressBuilder.anAddress().build());
});

Then('I should see the order confirmation', async ({ page }) => {
  await expect(page.getByRole('heading', { name: 'Thank you for your order' })).toBeVisible();
});

Then('the cart should show {int} item', async ({ homePage }, count: number) => {
  await expect(homePage.header.cartCount).toHaveText(String(count));
});
Enter fullscreen mode Exit fullscreen mode

Bonus: the Screenplay pattern

When page objects get huge, Screenplay models who does what: Actors perform Tasks and ask Questions. Serenity/JS has a full implementation for Playwright; here is the core idea in plain TypeScript:

// screenplay/core.ts
import { type Page } from '@playwright/test';

export interface Task      { performAs(actor: Actor): Promise<void>; }
export interface Question<T> { answeredBy(actor: Actor): Promise<T>; }

export class Actor {
  constructor(readonly name: string, readonly page: Page) {}

  async attemptsTo(...tasks: Task[]): Promise<void> {
    for (const task of tasks) await task.performAs(this);
  }

  asks<T>(question: Question<T>): Promise<T> {
    return question.answeredBy(this);
  }
}
Enter fullscreen mode Exit fullscreen mode
// screenplay/tasks.ts
import { LoginPage } from '../pages/LoginPage';
import { type Actor, type Task, type Question } from './core';

export const Login = {
  as: (user: string, pass: string): Task => ({
    performAs: async (actor: Actor) => {
      const loginPage = new LoginPage(actor.page);
      await loginPage.goto();
      await loginPage.loginAs(user, pass);
    },
  }),
};

export const WelcomeBanner = {
  text: (): Question<string> => ({
    answeredBy: async (actor: Actor) =>
      (await actor.page.getByTestId('welcome-banner').textContent()) ?? '',
  }),
};
Enter fullscreen mode Exit fullscreen mode
// tests/screenplay.spec.ts
import { test, expect } from '@playwright/test';
import { Actor } from '../screenplay/core';
import { Login, WelcomeBanner } from '../screenplay/tasks';

test('Priya can log in', async ({ page }) => {
  const priya = new Actor('Priya', page);
  await priya.attemptsTo(Login.as('admin', 'secret'));
  await expect(page.getByTestId('welcome-banner')).toBeVisible();
  expect(await priya.asks(WelcomeBanner.text())).toContain('Welcome');
});
Enter fullscreen mode Exit fullscreen mode

5. Hybrid framework

Idea: what most mature teams actually run. POM for pages, data-driven inputs, an optional BDD or keyword layer, and a shared core of fixtures, config, flows and reporting.

Patterns used: Facade, Strategy, Decorator, Observer, plus everything above.

Use it when: the suite is large, long-lived, or shared by several teams. Don't start here; grow into it.

Facade: hide multi-page journeys

Twenty tests all need "log in, add a product, check out". A Facade gives them one call. (ProductPage, CartPage and CheckoutPage follow the same style as LoginPage, each method returning the next page.)

// flows/CheckoutFlow.ts
import { type Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { CartPage } from '../pages/CartPage';
import { type User } from '../data/UserBuilder';
import { type Address } from '../data/AddressBuilder';

export class CheckoutFlow {
  constructor(private readonly page: Page) {}

  async purchase(user: User, product: string, address: Address) {
    const login = new LoginPage(this.page);
    await login.goto();
    const home = await login.loginAs(user.email, 'secret');
    const productPage = await home.openProduct(product);
    await productPage.addToCart();
    return this.checkoutWith(address);
  }

  async checkoutWith(address: Address) {
    const cart = await new CartPage(this.page).goto();
    const checkout = await cart.proceedToCheckout();
    await checkout.fillAddress(address);
    return checkout.placeOrder();   // returns ConfirmationPage
  }
}
Enter fullscreen mode Exit fullscreen mode
test('customer can buy a backpack', async ({ page }) => {
  const confirmation = await new CheckoutFlow(page).purchase(
    UserBuilder.aUser().build(), 'Backpack', AddressBuilder.anAddress().build(),
  );
  await expect(confirmation.heading).toHaveText('Thank you for your order');
});
Enter fullscreen mode Exit fullscreen mode

Strategy: log in through the UI or the API

Only your login tests need to click through the login form. Everything else can log in through the API in milliseconds. A Strategy makes that a one-line choice:

// auth/strategies.ts
import { type Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

export interface LoginStrategy {
  login(page: Page, user: string, pass: string): Promise<void>;
}

export class UiLogin implements LoginStrategy {
  async login(page: Page, user: string, pass: string) {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.loginAs(user, pass);
  }
}

export class ApiLogin implements LoginStrategy {
  async login(page: Page, user: string, pass: string) {
    // page.request shares cookies with the browser context,
    // so the session cookie from this call logs the page in.
    const res = await page.request.post('/api/login', { data: { username: user, password: pass } });
    if (!res.ok()) throw new Error(`API login failed: ${res.status()}`);
    await page.goto('/');
  }
}
Enter fullscreen mode Exit fullscreen mode

Expose it as a fixture option so each file (or project) can pick:

// fixtures/index.ts (additions)
import { type Page } from '@playwright/test';
import { env } from '../config/env';
import { UiLogin, ApiLogin, type LoginStrategy } from '../auth/strategies';

type Options = { loginVia: 'ui' | 'api' };
type Auth = { loggedInPage: Page };

export const test = base.extend<Pages & Options & Auth>({
  loginVia: ['api', { option: true }],

  loggedInPage: async ({ page, loginVia }, use) => {
    const strategy: LoginStrategy = loginVia === 'ui' ? new UiLogin() : new ApiLogin();
    await strategy.login(page, env.adminUser, env.adminPass);
    await use(page);
  },
  // ...page fixtures from earlier
});

// In a spec that must exercise the real form:
test.use({ loginVia: 'ui' });
Enter fullscreen mode Exit fullscreen mode

For whole-suite speed, Playwright's built-in setup project + storageState pattern logs in once and reuses the session across all tests.

Decorator: every page method becomes a report step

A TypeScript method Decorator wraps page-object methods in test.step(), so the HTML report and trace viewer read like a script, without touching the method bodies:

// utils/step.ts
import { test } from '@playwright/test';

export function step(title?: string) {
  return function <This, Args extends unknown[], Return>(
    target: (this: This, ...args: Args) => Promise<Return>,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Promise<Return>>,
  ) {
    return async function (this: This, ...args: Args): Promise<Return> {
      const name = title ?? `${(this as object).constructor.name}.${String(context.name)}`;
      return test.step(name, () => target.call(this, ...args));
    };
  };
}
Enter fullscreen mode Exit fullscreen mode
// pages/LoginPage.ts
import { step } from '../utils/step';

export class LoginPage extends BasePage {
  // ...

  @step('Log in')
  async loginAs(user: string, pass: string): Promise<HomePage> {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
    return new HomePage(this.page);
  }
}
Enter fullscreen mode Exit fullscreen mode

This uses standard TypeScript 5 decorators, so leave experimentalDecorators off in tsconfig.json. The same wrapper is a good place for timing, extra logging or retries around flaky third-party widgets.

Observer: react to test and page events

Playwright is event-driven, which makes the Observer pattern natural in two places.

1. A custom reporter observes the test run. For example, a summary you could post to Slack or Teams:

// reporters/summary-reporter.ts
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';

export default class SummaryReporter implements Reporter {
  private failed: string[] = [];
  private passed = 0;

  onTestEnd(test: TestCase, result: TestResult) {
    if (result.status === 'passed') this.passed++;
    else if (result.status === 'failed' || result.status === 'timedOut') {
      this.failed.push(test.titlePath().join(' › '));
    }
  }

  async onEnd(result: FullResult) {
    console.log(`\nRun ${result.status}: ${this.passed} passed, ${this.failed.length} failed`);
    this.failed.forEach(t => console.log(`  ✗ ${t}`));
    // e.g. await fetch(process.env.SLACK_WEBHOOK!, { method: 'POST', body: ... })
  }
}
Enter fullscreen mode Exit fullscreen mode
// playwright.config.ts
reporter: [['html'], ['./reporters/summary-reporter.ts']],
Enter fullscreen mode Exit fullscreen mode

2. An auto-fixture observes the page. This one fails any test that triggers an uncaught JavaScript error in the app, a bug your assertions would otherwise miss:

// fixtures/index.ts (addition)
export const test = base.extend<Pages & { failOnPageErrors: void }>({
  failOnPageErrors: [async ({ page }, use) => {
    const errors: string[] = [];
    page.on('pageerror', err => errors.push(err.message));
    await use();
    expect(errors, 'Uncaught errors in the page').toEqual([]);
  }, { auto: true }],
  // ...other fixtures
});
Enter fullscreen mode Exit fullscreen mode

Suggested project structure

.
├── playwright.config.ts
├── config/          env.ts (Singleton)
├── fixtures/        index.ts (DI: pages, auth, auto-fixtures)
├── pages/           BasePage, LoginPage, HomePage, CartPage, App (Factory)
├── components/      HeaderComponent, ProductCard, Modal
├── flows/           CheckoutFlow, OnboardingFlow (Facades)
├── auth/            UiLogin, ApiLogin (Strategy)
├── data/            UserBuilder, AddressBuilder, readers.ts, *.csv, *.json
├── keywords/        registry.ts, engine.ts, cases/*.json
├── features/        *.feature, steps/*.steps.ts
├── reporters/       summary-reporter.ts (Observer)
├── utils/           step.ts (Decorator)
└── tests/           *.spec.ts
Enter fullscreen mode Exit fullscreen mode

Pattern cheat sheet

Pattern Problem it solves Playwright example
Page Object Locators duplicated across specs LoginPage, HomePage
Component Object Same widget on many pages HeaderComponent
Singleton One shared config env.ts (frozen ES module)
Factory Creating browsers or objects by type projects, App, readerFor()
Dependency Injection Manual setup/teardown in every test Fixtures via test.extend()
Builder Unreadable test data UserBuilder.aUser().withRole('admin')
Strategy Swapping behaviour at runtime UiLogin vs ApiLogin
Command Mapping keywords to actions keywords registry
Facade Long multi-page flows repeated in specs CheckoutFlow.purchase()
Decorator Adding steps/logging without editing bodies @step()
Observer Reacting to run or page events Custom Reporter, page.on('pageerror')
Screenplay Page objects that grew too big actor.attemptsTo(Login.as(...))

When to use what

Framework Use it when Avoid it when
POM Any UI suite Pure API testing (use request fixtures + builders)
Data-driven Same flow, many inputs Every test has a different flow
Keyword-driven Non-coders own test cases Only engineers write tests
BDD Business reads and reviews scenarios Nobody outside QA reads the features
Hybrid Large, multi-team, long-lived suites Small projects or early-stage products

POM in Playwright: best practices and pitfalls

Best practices

  • Prefer user-facing locators. getByRole, getByLabel and getByTestId survive redesigns; long CSS and XPath chains don't.
  • Expose Locators, assert in tests. Unlike Selenium, exposing locators is idiomatic in Playwright because web-first assertions need them: await expect(loginPage.error).toHaveText(...).
  • Name methods by intent. loginAs(user, pass) survives a UI redesign; clickButton3() doesn't.
  • Return the next page from navigation methods so flows chain and autocomplete guides you.
  • Inject pages with fixtures instead of calling new LoginPage(page) in every test.
  • Compose, don't inherit. Headers, tables and modals are components, not parent classes.
  • Set up data through the API with request and builders; use the UI only for the behaviour under test.

Common pitfalls

Hard waits.

// ❌ Slow and still flaky
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();

// ✅ Locators auto-wait; assertions auto-retry
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Non-retrying assertions.

// ❌ Checks once, no retry: flaky
expect(await loginPage.error.isVisible()).toBe(true);

// ✅ Web-first assertion retries until timeout
await expect(loginPage.error).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Missing await. A forgotten await on a page method lets the test race ahead. Enable the @typescript-eslint/no-floating-promises lint rule to catch it.

Using ElementHandles. page.$() and page.$$() return handles that can go stale. Use Locators everywhere.

God pages. A 1,500-line DashboardPage is a sign it needs splitting into components.

Assertions hidden inside page methods.

// ❌ Can't reuse for a negative test, and failures point at the page, not the test
async verifyLoginSuccess() { await expect(this.page.getByTestId('welcome-banner')).toBeVisible(); }

// ✅ Expose the locator; let the test decide
readonly welcome = this.page.getByTestId('welcome-banner');
Enter fullscreen mode Exit fullscreen mode

Deep inheritance. BasePage → AuthenticatedPage → AdminPage → AdminUsersPage becomes impossible to change. Keep one level (BasePage) and compose the rest.


When to outgrow POM

If page classes keep growing, or several teams share one suite, look at Screenplay (shown in the BDD section). It splits behaviour into actors, tasks and questions, so no single class grows unbounded. For most teams, though, clean POM with components and fixtures is enough for years.

Takeaway

Start with POM + fixtures; Playwright already gives you the factory, DI and parallel isolation. Add data-driven specs as soon as inputs multiply. Add keywords or BDD only when there's a real audience for them. Reach for Facade, Strategy, Decorator and Observer as the suite grows.

Patterns should remove duplication, not add ceremony. If a pattern doesn't make the next test easier to write or the next failure easier to debug, you don't need it yet.


Which framework style does your team run with Playwright, and which pattern saved you the most pain? Let me know in the comments. 👇

Top comments (1)

Collapse
 
devsupport profile image
Dev Support •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

​​