Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Page Object Model vs Fixtures in Playwright: One Suite Written Three Ways

Should you use page objects, fixtures, or plain hooks in Playwright? We wrote the same three Sauce Demo tests all three ways, ran them, and measured the differences: how much code each needs, how many places a locator lives, where a failure points when a locator breaks, and what a beforeEach does to a test that doesn't need it.

QA Vibes EditorialPublished 8 minTested with Playwright 1.63.0Revision history ↓

Key takeaways

  • For three tests, plain tests with a beforeEach were the smallest: 26 lines, against 60 with page objects and 70 with page objects and fixtures.
  • Page objects pay off when the same locator is used in many tests; here only one locator appeared twice, so they cost more than they saved.
  • When a page-object locator broke, Playwright pointed at the page object's line first and the test's line second; with inline locators, straight at the test.
  • A beforeEach runs for every test in the file: our login-page test started on the products page. With a fixture, it started on a blank page because it didn't ask to log in.
  • Start with plain tests. Move a locator into a page object when you find yourself editing it in several places, and deliver page objects through fixtures when setup differs between tests.
Contents (10 sections)

Introduction

Ask how to structure Playwright tests and you'll get two answers: use the Page Object Model, or use fixtures. Playwright's own documentation covers each on its own page, but neither page runs the alternatives side by side.

So we did. This guide takes three small tests against Sauce Demo, a practice shop, and writes them three ways:

  1. Hooks: plain tests, with login in a beforeEach.
  2. Page objects: the same tests calling classes that hold the locators.
  3. Fixtures: the same page objects, handed to each test by a custom fixture that logs in only when a test asks for it.

All three ran on Playwright 1.63.0 on 21 September 2026. Then we measured what's different: the amount of code, how many places each locator lives, where a failure points when a locator breaks, and what a beforeEach does to a test that doesn't need it.

The setup

All three versions share one config. Sauce Demo marks its elements with data-test, so testIdAttribute lets getByTestId use them:

import { defineConfig } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  reporter: "list",
  use: { baseURL: "https://www.saucedemo.com", testIdAttribute: "data-test" },
});

The three tests: logging in shows the products page, adding the backpack makes the cart badge say 1, and a customer can check out.

Version 1: plain tests with a beforeEach

tests/hooks/shop.spec.ts:

import { test, expect } from "@playwright/test";
 
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
test.beforeEach(async ({ page }) => {
  await page.goto("/");
  await page.getByPlaceholder("Username").fill("standard_user");
  await page.getByPlaceholder("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Login" }).click();
});
 
test("logging in shows the products", async ({ page }) => {
  await expect(page.getByTestId("title")).toHaveText("Products");
});
 
test("adding a product updates the cart badge", async ({ page }) => {
  await page.getByTestId("add-to-cart-sauce-labs-backpack").click();
  await expect(page.getByTestId("shopping-cart-badge")).toHaveText("1");
});
 
test("a customer can check out", async ({ page }) => {
  await page.getByTestId("add-to-cart-sauce-labs-backpack").click();
  await page.getByTestId("shopping-cart-link").click();
  await page.getByRole("button", { name: "Checkout" }).click();
  await page.getByPlaceholder("First Name").fill("Ada");
  await page.getByPlaceholder("Last Name").fill("Lovelace");
  await page.getByPlaceholder("Zip/Postal Code").fill("10115");
  await page.getByRole("button", { name: "Continue" }).click();
  await page.getByRole("button", { name: "Finish" }).click();
  await expect(page.getByTestId("complete-header")).toHaveText("Thank you for your order!");
});
Running 3 tests using 1 worker
 
  ok 1 tests\hooks\shop.spec.ts:12:5 › logging in shows the products (808ms)
  ok 2 tests\hooks\shop.spec.ts:16:5 › adding a product updates the cart badge (589ms)
  ok 3 tests\hooks\shop.spec.ts:21:5 › a customer can check out (933ms)
 
  3 passed (3.7s)

Everything is in one file you can read top to bottom. Login lives in one place, the beforeEach. The only locator that appears twice is the add-to-cart button.

Version 2: page objects

A page object is a class that knows how to use one page: which locators it has and what a user does there. The tests call its methods instead of using locators directly.

pages/shop.ts:

import type { Locator, Page } from "@playwright/test";
 
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
export class LoginPage {
  constructor(private readonly page: Page) {}
 
  async logIn(username = "standard_user", password = PASSWORD) {
    await this.page.goto("/");
    await this.page.getByPlaceholder("Username").fill(username);
    await this.page.getByPlaceholder("Password").fill(password);
    await this.page.getByRole("button", { name: "Login" }).click();
  }
}
 
export class InventoryPage {
  readonly title: Locator;
  readonly cartBadge: Locator;
 
  constructor(private readonly page: Page) {
    this.title = page.getByTestId("title");
    this.cartBadge = page.getByTestId("shopping-cart-badge");
  }
 
  async addToCart(productId: string) {
    await this.page.getByTestId(`add-to-cart-${productId}`).click();
  }
 
  async openCart() {
    await this.page.getByTestId("shopping-cart-link").click();
  }
}
 
export class CheckoutPage {
  readonly completeHeader: Locator;
 
  constructor(private readonly page: Page) {
    this.completeHeader = page.getByTestId("complete-header");
  }
 
  async checkOut(first: string, last: string, postalCode: string) {
    await this.page.getByRole("button", { name: "Checkout" }).click();
    await this.page.getByPlaceholder("First Name").fill(first);
    await this.page.getByPlaceholder("Last Name").fill(last);
    await this.page.getByPlaceholder("Zip/Postal Code").fill(postalCode);
    await this.page.getByRole("button", { name: "Continue" }).click();
    await this.page.getByRole("button", { name: "Finish" }).click();
  }
}

tests/pages/shop.spec.ts:

import { test, expect } from "@playwright/test";
import { CheckoutPage, InventoryPage, LoginPage } from "../../pages/shop";
 
test.beforeEach(async ({ page }) => {
  await new LoginPage(page).logIn();
});
 
test("logging in shows the products", async ({ page }) => {
  await expect(new InventoryPage(page).title).toHaveText("Products");
});
 
test("adding a product updates the cart badge", async ({ page }) => {
  const inventory = new InventoryPage(page);
  await inventory.addToCart("sauce-labs-backpack");
  await expect(inventory.cartBadge).toHaveText("1");
});
 
test("a customer can check out", async ({ page }) => {
  const inventory = new InventoryPage(page);
  await inventory.addToCart("sauce-labs-backpack");
  await inventory.openCart();
  const checkout = new CheckoutPage(page);
  await checkout.checkOut("Ada", "Lovelace", "10115");
  await expect(checkout.completeHeader).toHaveText("Thank you for your order!");
});
Running 3 tests using 1 worker
 
  ok 1 tests\pages\shop.spec.ts:8:5 › logging in shows the products (756ms)
  ok 2 tests\pages\shop.spec.ts:12:5 › adding a product updates the cart badge (614ms)
  ok 3 tests\pages\shop.spec.ts:18:5 › a customer can check out (1.1s)
 
  3 passed (4.2s)

The test file has no locators at all. It reads as what the user does, and every locator is in pages/shop.ts.

Version 3: page objects delivered by fixtures

A fixture is something a test asks for by name in its arguments, like the built-in page. Playwright sets it up before the test and tears it down after, and only for tests that ask for it. Here, asking for inventory logs in first and hands over an InventoryPage.

pages/fixtures.ts:

import { test as base } from "@playwright/test";
import { CheckoutPage, InventoryPage, LoginPage } from "./shop";
 
type ShopFixtures = {
  inventory: InventoryPage;
  checkout: CheckoutPage;
};
 
// Asking for `inventory` logs in first; a test that asks for nothing starts on a blank page.
export const test = base.extend<ShopFixtures>({
  inventory: async ({ page }, use) => {
    await new LoginPage(page).logIn();
    await use(new InventoryPage(page));
  },
  checkout: async ({ page }, use) => {
    await use(new CheckoutPage(page));
  },
});
 
export { expect } from "@playwright/test";

tests/fixtures/shop.spec.ts:

import { test, expect } from "../../pages/fixtures";
 
test("logging in shows the products", async ({ inventory }) => {
  await expect(inventory.title).toHaveText("Products");
});
 
test("adding a product updates the cart badge", async ({ inventory }) => {
  await inventory.addToCart("sauce-labs-backpack");
  await expect(inventory.cartBadge).toHaveText("1");
});
 
test("a customer can check out", async ({ inventory, checkout }) => {
  await inventory.addToCart("sauce-labs-backpack");
  await inventory.openCart();
  await checkout.checkOut("Ada", "Lovelace", "10115");
  await expect(checkout.completeHeader).toHaveText("Thank you for your order!");
});
Running 3 tests using 1 worker
 
  ok 1 tests\fixtures\shop.spec.ts:3:5 › logging in shows the products (671ms)
  ok 2 tests\fixtures\shop.spec.ts:7:5 › adding a product updates the cart badge (596ms)
  ok 3 tests\fixtures\shop.spec.ts:12:5 › a customer can check out (899ms)
 
  3 passed (3.6s)

The tests are the shortest of the three. There's no beforeEach: the login happens because the test asked for inventory.

What we measured

How much code

Version Test file Support files Total (non-blank lines)
Hooks 26 none 26
Page objects 21 shop.ts: 39 60
Fixtures 14 shop.ts: 39, fixtures.ts: 17 70

For three tests, the structured versions are more than twice the size. Page objects are an investment: you write the class once, and it pays back each time another test uses it. Three tests aren't enough to pay it back.

How many places each locator lives

The hooks version makes 15 locator calls in its test file; the page objects make 14, all in shop.ts. The difference is one locator: the add-to-cart button, which the hooks version uses in two tests and the page objects keep in one method. If Sauce Demo renamed that button, you'd edit two lines in version 1 and one in versions 2 and 3.

That's the real case for page objects, and it grows with the suite: a login form used by 40 tests is one edit instead of 40. Here, the beforeEach already gave the hooks version a single place for the login steps, which is the locator most suites repeat most.

Where a failure points when a locator breaks

To see what a broken locator looks like, we changed the Checkout button's name to "Check out" in a copy of each version and ran the checkout test with a 10-second timeout. All three failed the same way, locator.click: Test timeout of 10000ms exceeded, waiting for getByRole('button', { name: 'Check out' }). What differed was where Playwright pointed.

With inline locators, the error pointed at the test:

> 24 |   await page.getByRole("button", { name: "Check out" }).click();
    at tests\hooks\shop.spec.ts:24:57

With page objects, and with fixtures, it pointed at the page object first and the test second:

> 42 |     await this.page.getByRole("button", { name: "Check out" }).click();
    at CheckoutPage.checkOut (pages\shop.ts:42:64)
    at tests\pages\shop.spec.ts:23:18

(We shortened the paths; the full output has absolute ones.) Neither is worse. With page objects you land where the fix goes, and the second line tells you which test step called it.

What a beforeEach does to a test that doesn't need it

Then we added a fourth test to versions 1 and 3: clicking Login with empty fields should show "Username is required". This test is about the login page, so it must not start logged in.

In the hooks file, our first attempt clicked Login straight away, and it failed after 10 seconds waiting for a Login button that wasn't there. The beforeEach had already logged in, so the test started on the products page. Logging the URL at the start of a test confirmed it: https://www.saucedemo.com/inventory.html in the hooks file, about:blank in the fixtures file.

Adding await page.goto("/") fixed the hooks version: it passed 3 times out of 3, because Sauce Demo shows the login form at / even to a logged-in user. But the login in beforeEach still runs, for a test that doesn't use it, and the test only works because of how this site treats /.

In the fixtures file the same test asks for page and nothing else, so no login happens. It passed 3 times out of 3 without depending on that behavior. That's what the Playwright docs mean when they call fixtures "on-demand": setup belongs to the tests that ask for it, not to every test in the file.

Which one to use

  • Start with plain tests and a beforeEach while the suite is small and every test needs the same setup. It was the least code here, and every failure points at the test.
  • Move locators into a page object when you catch yourself editing the same one in several tests. That is the change page objects save you from; before it happens, they're overhead.
  • Deliver setup through fixtures when tests need different setup, like a login-page test next to logged-in tests, or when you'd otherwise nest describe blocks just to change a beforeEach.

The three can be mixed: the fixtures version still uses the same page objects, and a fixture can be as simple as a function that logs in.

Exercise

In the fixtures version, add a cart fixture that depends on inventory, adds the backpack, and opens the cart. Rewrite the checkout test to ask for cart and checkout. Then count the lines again, and decide whether the fixture earned its place for one test.

Conclusion

On three tests, page objects and fixtures made the suite bigger, not smaller. What they bought was one place per locator, failures that point where the fix goes, and setup that only runs for the tests that need it. Those matter as a suite grows; measure your own before you restructure it.

Sources and further reading

Tools mentioned

PlaywrightUI AutomationOpen source

Links go to each tool’s official site. How we choose and link tools

Revision history

First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Check your own tests

Playwright test reviewer

Paste a test to find fixed waits, missing awaits, and assertions that can never fail.