Test Automation with Playwright POM

Build a professional Playwright test suite using the Page Object Model pattern.

Introduction

30 Second Summary

Every time you click a button on an online store, there is a good chance an automated test verified that button works before you ever saw it. Test automation is how companies prevent bugs from reaching customers.

In this project, you will build a professional test automation framework using Playwright against a real e-commerce demo site. You'll start by writing tests the way beginners do, experience the exact maintenance problem that makes that approach break down, then refactor into the Page Object Model that production teams rely on.

What You'll Build

Run a single command in your terminal and watch your framework automatically log into a store, browse products, add items to a cart, and verify the entire purchase flow passes.

By the end of this project, you'll have:

  • Run a single command and watch automated tests log in with valid and invalid credentials, browse products, add items to a cart, and verify checkout against a live e-commerce site.
  • Change a selector in one file and see every test across your suite automatically use the updated version, thanks to the Page Object Model architecture.
  • Open a browser-based HTML test report to inspect individual test results, step timings, and DOM snapshots. Use the Trace Viewer to replay exactly what happened when a test failed.
  • Secret Mission: Build custom Playwright fixtures for pre-authenticated state and complete a full checkout flow test.

Are there any prerequisites?

This project assumes basic JavaScript knowledge and some familiarity with Playwright (writing simple tests). You should have Node.js and VS Code installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Playwright Project

Before you can write a single test, you need a working Playwright project with browser binaries downloaded and ready.

In this step, you will scaffold a new Playwright project, configure it to target the SauceDemo e-commerce site, and confirm everything works with a quick smoke test.

In this step, get ready to:
  • Create and initialize a Playwright project with browser binaries.
  • Configure Playwright for single-browser testing against SauceDemo.
  • Verify the setup with a passing smoke test.
Create a new project directory and initialize Playwright
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
mkdir playwright-saucedemo
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the playwright-saucedemo folder, and click Open.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key below Esc).

What is the integrated terminal?

VS Code has a built-in terminal panel at the bottom of the window. It automatically opens in your project folder, so every command you run targets the right directory.

  • Initialize your Playwright project by running this command:
npm init playwright@latest

What will this command ask me?

This command launches an interactive setup wizard. It will prompt you for several choices. Here is exactly what to enter:

  • Select JavaScript when asked for the language.
  • Keep the default tests folder name by pressing Enter.
  • Select false (No) for the GitHub Actions workflow.
  • Select true (Yes) to install browsers.

Browser installation downloads Chromium and other browser binaries. This may take a minute depending on your internet connection.

You will see output showing browsers being downloaded and files being created. Once it finishes, your project has everything it needs.

  • Delete the generated example test file by running:
rm tests/example.spec.js
  • Confirm the file is gone by running:
ls tests/

You should see an empty output, confirming the tests/ directory has no files in it.

Seeing 'rm: No such file or directory'?

If the file was named differently, check what exists with ls tests/ and delete whatever .spec.js file you find there. The exact filename depends on your Playwright version.

help me find and delete the example spec file Playwright generated

Configure Playwright for the project

The scaffolding created a config file with multiple browsers and options you do not need yet. You will replace it with a focused config that targets only Chromium and points at SauceDemo.

  • In the VS Code Explorer sidebar on the left, click playwright.config.js to open it.
  • Select all the existing content (Cmd+A on macOS, Ctrl+A on Windows) and replace it with the following code:
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'https://www.saucedemo.com',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

What does this config do?

  • baseURL: 'https://www.saucedemo.com' lets you write page.goto('/') instead of the full URL in every test.
  • reporter: 'html' generates a visual HTML report after each test run that you can share in interviews.
  • trace: 'on-first-retry' captures DOM snapshots, network requests, and screenshots when a test fails and retries. This gives you a time-travel debugger for failures.
  • The single chromium project keeps test runs fast during development. You can add Firefox and WebKit later.
  • Save playwright.config.js (Cmd+S on macOS, Ctrl+S on Windows).

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'https://www.saucedemo.com',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});
Verify the setup with a smoke test

A smoke test is a tiny test that confirms your toolchain is wired up correctly. You will create one that simply visits SauceDemo and checks the page title.

  • Create a new file called smoke.spec.js inside the tests/ folder by right-clicking tests in the Explorer sidebar and selecting New File.
  • Paste the following code into tests/smoke.spec.js:
import { test, expect } from '@playwright/test';

test('smoke test - page title', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/Swag Labs/);
});

What does this test do?

  • page.goto('/') navigates to the baseURL you set in the config (SauceDemo's login page).
  • expect(page).toHaveTitle(/Swag Labs/) is a web-first assertion that waits for the page title to match. If the site loads and the title contains "Swag Labs", the test passes.
  • Save tests/smoke.spec.js.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before running the test.

ⓧ I'd like to double check the full code

import { test, expect } from '@playwright/test';

test('smoke test - page title', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/Swag Labs/);
});

Before you run this, what do you think will happen? Will the test pass or fail?

  • Run your test suite by running this command in the terminal:
npx playwright test

You should see output showing 1 test passed in the Chromium project. The test navigated to SauceDemo and confirmed the page title contains "Swag Labs".

Test failing or seeing an error?

  • If you see a timeout error, check your internet connection. Playwright needs to reach https://www.saucedemo.com to run the test.
  • If you see "Cannot find module", make sure playwright.config.js is saved and contains the exact config from the previous substep.
  • If you see "No tests found", confirm your file is named smoke.spec.js (not smoke.js) and lives inside the tests/ folder.

help me debug why my Playwright smoke test is failing

Your Playwright project is configured and confirmed working. Next up, you will write real login tests against SauceDemo and see them execute in the browser.

Write Your First Login Tests

Your Playwright project is set up and the smoke test proves it can reach SauceDemo. Now it is time to write real tests.

Login is the gateway to everything on an e-commerce site. It is also where the most common QA scenarios live: valid credentials, invalid credentials, and missing fields. Writing login tests first gives you an immediate visible result and covers the patterns interviewers ask about most.

In this step, get ready to:
  • Explore SauceDemo in DevTools to identify automation-friendly selectors.
  • Write 4 login tests covering valid login, invalid credentials, and empty field validation.
  • Run the tests and watch them execute in a real browser.
Explore SauceDemo and identify selectors

Before you write any test code, you need to know which HTML attributes to target. SauceDemo uses data-test attributes specifically designed for automation. These attributes stay stable even when the UI is redesigned.

  • Open https://www.saucedemo.com in your browser.
  • Right-click on the username field and select Inspect to open DevTools.
  • Look at the highlighted element. You will see a data-test attribute on it.

Note the selectors you will use in your tests:

  • Username input: [data-test="username"]
  • Password input: [data-test="password"]
  • Login button: [data-test="login-button"]
  • Error message container: [data-test="error"]

Also note the test credentials displayed directly on the login page: standard_user / secret_sauce.

Why data-test attributes?

Production teams add dedicated data-test attributes to their HTML specifically for automation. Unlike CSS class names or IDs that change with styling updates, data-test attributes have no visual purpose and remain stable across redesigns.

Write login tests with inline selectors

Now you will turn those selectors into working tests. You will write all four tests directly in a single file using inline selectors. This approach is quick to write and easy to read for a small file.

  • Delete the tests/smoke.spec.js file you created earlier. You will replace it with proper login tests.
  • Create a new file called tests/login.spec.js in the tests folder.
  • Add the imports, describe block, beforeEach hook, and first two tests by pasting this code:
import { test, expect } from '@playwright/test';

test.describe('Login Tests', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.locator('[data-test="username"]').fill('standard_user');
    await page.locator('[data-test="password"]').fill('secret_sauce');
    await page.locator('[data-test="login-button"]').click();
    await expect(page).toHaveURL(/inventory/);
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.locator('[data-test="username"]').fill('invalid_user');
    await page.locator('[data-test="password"]').fill('wrong_password');
    await page.locator('[data-test="login-button"]').click();
    await expect(page.locator('[data-test="error"]')).toContainText('Username and password do not match any user in this service');
  });

What does this code do?

  • test.describe groups related tests under the label "Login Tests" so the report stays organized.
  • test.beforeEach navigates to the login page before every test. Since baseURL is configured, page.goto('/') loads SauceDemo's homepage automatically.
  • The valid login test fills credentials and asserts the URL changes to /inventory after a successful login.
  • The invalid credentials test checks that the correct error message appears when login fails.
  • Add the empty field validation tests below the invalid credentials test (still inside the describe block):
  test('should show error for empty username', async ({ page }) => {
    await page.locator('[data-test="username"]').fill('');
    await page.locator('[data-test="password"]').fill('secret_sauce');
    await page.locator('[data-test="login-button"]').click();
    await expect(page.locator('[data-test="error"]')).toContainText('Username is required');
  });

  test('should show error for empty password', async ({ page }) => {
    await page.locator('[data-test="username"]').fill('standard_user');
    await page.locator('[data-test="password"]').fill('');
    await page.locator('[data-test="login-button"]').click();
    await expect(page.locator('[data-test="error"]')).toContainText('Password is required');
  });
});

What do these tests verify?

These two tests cover empty field validation. Each fills one field and leaves the other blank, then asserts the exact error message SauceDemo displays. The closing }); on the last line closes the test.describe block you opened in the first chunk.

  • Save tests/login.spec.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before running the tests.

ⓧ I'd like to double check the full code

Your tests/login.spec.js should contain both chunks in order. Scroll up and confirm:

  • The first chunk starts with the import statement and ends after the invalid credentials test (with a trailing });).
  • The second chunk starts with the empty username test and ends with }); closing the test.describe block.
  • There are 4 tests total inside the describe block, plus a beforeEach hook.
Run tests and see results

Your login tests are written. Before you run them, think about what you expect to see. Do you think all 4 will pass on the first run?

  • Run the login tests by executing this command:
npx playwright test tests/login.spec.js

You should see output showing 4 passed tests with a summary line like "4 passed".

Tests failing?

  • Check that tests/login.spec.js is saved. Unsaved changes are invisible to Playwright.
  • Make sure the closing }); for the describe block is present at the very end of the file. A missing brace causes a syntax error.
  • Confirm you are connected to the internet. The tests navigate to https://www.saucedemo.com and need network access.

help me debug my failing Playwright login tests

Now watch the tests run visually. This is where the magic becomes tangible.

  • Run the tests in headed mode to see the browser open by executing this command:
npx playwright test tests/login.spec.js --headed

A Chromium browser window opens and you can watch Playwright fill in fields, click buttons, and navigate pages at speed. Each test runs in isolation with a fresh page.

Why headed mode?

By default Playwright runs headless (no visible browser). The --headed flag opens a real browser window so you can see exactly what your tests are doing. This is invaluable for debugging and for understanding how Playwright interacts with the page.

You now have 4 passing login tests covering the most important authentication scenarios. Next up, you will add tests for the inventory page and discover why inline selectors become a maintenance nightmare as your test suite grows.

Add Inventory Tests and Hit the Maintenance Wall

Your login tests are passing. Now it's time to test the next page users interact with: the inventory (product listing) page.

To test anything on the inventory page, you first need to log in. Every inventory test must repeat the same login steps you already wrote. This duplication creates a real problem that breaks professional test suites at scale.

In this step, get ready to:
  • Write inventory tests that duplicate the login flow.
  • Run the full suite and confirm all tests pass.
  • Experience the maintenance problem that motivates the Page Object Model.
Write inventory tests with duplicated login code

The inventory page shows products after a successful login. To test it, each test needs the full login sequence before it can check anything on the page.

  • Create a new file called tests/inventory.spec.js and add the following code:
import { test, expect } from '@playwright/test';

test.describe('Inventory Tests', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
    await page.locator('[data-test="username"]').fill('standard_user');
    await page.locator('[data-test="password"]').fill('secret_sauce');
    await page.locator('[data-test="login-button"]').click();
  });

  test('should display products title after login', async ({ page }) => {
    await expect(page.locator('[data-test="title"]')).toHaveText('Products');
  });
});

What does this code do?

The test.beforeEach block runs the full login flow before every single test in this file. Those are the same username, password, and login button selectors you already used in login.spec.js.

The first test checks that the page title reads "Products" after login, confirming you landed on the inventory page.

  • Save tests/inventory.spec.js.
  • Run the test suite to confirm both files execute by running:
npx playwright test

You should see 5 tests pass (4 login + 1 inventory).

Tests failing?

  • Make sure the beforeEach includes await page.goto('/') before the login steps. Without it, the page never loads.
  • Check that the selector strings match exactly, including the quotes around attribute values.

Why are my inventory tests failing after login?

  • Add the following test below your first test, inside the test.describe block:
  test('should add item to cart and update badge', async ({ page }) => {
    // Find the specific product card by its text content
    const item = page.locator('[data-test="inventory-item"]').filter({ hasText: 'Sauce Labs Backpack' });
    await item.getByRole('button', { name: 'Add to cart' }).click();
    // Verify the cart badge updates to reflect one item
    await expect(page.locator('[data-test="shopping-cart-badge"]')).toHaveText('1');
  });

What does this test do?

The .filter({ hasText: ... }) method narrows down which inventory item to interact with. Then getByRole('button', { name: 'Add to cart' }) finds the button inside that specific product card.

  • Save tests/inventory.spec.js and confirm the new test passes by running:
npx playwright test tests/inventory.spec.js

You should see 2 tests pass in this file.

Cart badge test failing?

  • Confirm the product name matches exactly: Sauce Labs Backpack. This is case-sensitive.
  • Make sure the button name is Add to cart with a capital A and lowercase t.

Why is my add-to-cart test failing in Playwright?

  • Add one more test below it that adds multiple items to the cart:
  test('should add multiple items to cart', async ({ page }) => {
    // Add the first product
    const backpack = page.locator('[data-test="inventory-item"]').filter({ hasText: 'Sauce Labs Backpack' });
    await backpack.getByRole('button', { name: 'Add to cart' }).click();
    // Add a second product
    const bikeLight = page.locator('[data-test="inventory-item"]').filter({ hasText: 'Sauce Labs Bike Light' });
    await bikeLight.getByRole('button', { name: 'Add to cart' }).click();
    // Verify the cart badge shows the total count
    await expect(page.locator('[data-test="shopping-cart-badge"]')).toHaveText('2');
  });

What does this test do?

This test adds two different products to the cart and verifies the badge updates to "2". It proves the cart accumulates items correctly across multiple interactions.

  • Save tests/inventory.spec.js.

Before you run the full suite, how many total tests do you expect to pass across both files?

  • Run the full test suite by running:
npx playwright test

You should see 7 tests pass: 4 from login.spec.js and 3 from inventory.spec.js. All green.

Not seeing 7 pass?

  • Confirm the second product name is exactly Sauce Labs Bike Light. Product names are case-sensitive.
  • Check that both add-to-cart tests are inside the test.describe block's closing brackets. A test outside the describe won't share the beforeEach login setup.

Help me debug why my full Playwright test suite is not passing all 7 tests.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

import { test, expect } from '@playwright/test';

test.describe('Inventory Tests', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
    await page.locator('[data-test="username"]').fill('standard_user');
    await page.locator('[data-test="password"]').fill('secret_sauce');
    await page.locator('[data-test="login-button"]').click();
  });

  test('should display products title after login', async ({ page }) => {
    await expect(page.locator('[data-test="title"]')).toHaveText('Products');
  });

  test('should add item to cart and update badge', async ({ page }) => {
    const item = page.locator('[data-test="inventory-item"]').filter({ hasText: 'Sauce Labs Backpack' });
    await item.getByRole('button', { name: 'Add to cart' }).click();
    await expect(page.locator('[data-test="shopping-cart-badge"]')).toHaveText('1');
  });

  test('should add multiple items to cart', async ({ page }) => {
    const backpack = page.locator('[data-test="inventory-item"]').filter({ hasText: 'Sauce Labs Backpack' });
    await backpack.getByRole('button', { name: 'Add to cart' }).click();
    const bikeLight = page.locator('[data-test="inventory-item"]').filter({ hasText: 'Sauce Labs Bike Light' });
    await bikeLight.getByRole('button', { name: 'Add to cart' }).click();
    await expect(page.locator('[data-test="shopping-cart-badge"]')).toHaveText('2');
  });
});
Experience the maintenance problem

All 7 tests pass. The suite works perfectly. So what is the problem?

  • Open tests/login.spec.js and count how many times you see [data-test="username"].
  • Now open tests/inventory.spec.js and count the same selector there.

The login selectors ([data-test="username"], [data-test="password"], [data-test="login-button"]) appear across both files.

Why is this a problem?

Imagine the development team changes the login button from [data-test="login-button"] to something else. You would need to hunt through every test file and update every occurrence.

With 2 files this is annoying. With 50 test files across a real product, it becomes a maintenance nightmare. This is exactly why production teams use the Page Object Model: one class per page, selectors defined once, changes propagate automatically.

You have felt the pain firsthand. Your tests work, but the duplicated selectors are a ticking time bomb. In the next step, you will solve this permanently with the Page Object Model pattern.

Refactor to Page Object Model

In the last step, you saw login selectors scattered across both test files. If the login button's selector ever changes, you'd have to hunt through every file and update it manually.

The Page Object Model solves this by giving each page its own class. Selectors live in one place per page. Change a selector once, and every test that uses that page object picks up the update automatically.

In this step, get ready to:
  • Create LoginPage and InventoryPage classes in a new pages/ directory.
  • Refactor your login tests to use the LoginPage class.
  • Refactor your inventory tests to use both page objects.
Create page object classes

A page object is a class that represents one page of your application. It holds all the locators for that page and exposes methods for the actions a user can take.

  • Create a pages/ directory in your project root by running this command:
mkdir pages
  • Create a new file called LoginPage.js inside the pages/ folder and paste in the following code:
import { expect } from '@playwright/test';

export class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameInput = page.locator('[data-test="username"]');
    this.passwordInput = page.locator('[data-test="password"]');
    this.loginButton = page.locator('[data-test="login-button"]');
    this.errorMessage = page.locator('[data-test="error"]');
  }

  async goto() {
    await this.page.goto('/');
  }

  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

What does this class do?

  • The constructor stores locators for each element on the login page. These are the same selectors you used inline before, now defined once.
  • The goto() method navigates to the login page using your configured baseURL.
  • The login() method encapsulates the full login action: fill username, fill password, click the button.
  • Create another file called InventoryPage.js inside pages/ and paste in the following code:
export class InventoryPage {
  constructor(page) {
    this.page = page;
    this.title = page.locator('[data-test="title"]');
    this.inventoryItems = page.locator('[data-test="inventory-item"]');
    this.cartBadge = page.locator('[data-test="shopping-cart-badge"]');
    this.cartLink = page.locator('[data-test="shopping-cart-link"]');
  }

  async addItemToCart(itemName) {
    const item = this.inventoryItems.filter({ hasText: itemName });
    await item.getByRole('button', { name: 'Add to cart' }).click();
  }

  async goToCart() {
    await this.cartLink.click();
  }
}

What does this class do?

  • The constructor stores locators for the inventory page elements: the title, inventory items, cart badge, and cart link.
  • The addItemToCart() method filters inventory items by name and clicks the add-to-cart button on the matching one.
  • The goToCart() method clicks the cart link to navigate to the cart page.
  • Save both files.
  • Confirm the files were created by running:
ls pages/

You should see LoginPage.js and InventoryPage.js listed.

Don't see the files?

Make sure you created the files inside the pages/ folder (not in the project root or tests/). Check the VS Code Explorer sidebar to verify the folder structure.

help me find where my page object files ended up

Refactor login tests to use LoginPage

Now that LoginPage exists, you can replace all the inline selectors in your login tests with calls to the page object.

  • Open tests/login.spec.js and replace its entire contents with the following imports and beforeEach setup:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage.js';

test.describe('Login Tests', () => {
  let loginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

What changed here?

  • You import LoginPage from the pages/ directory.
  • The beforeEach hook creates a fresh LoginPage instance and navigates to the login page before every test.
  • Instead of writing page.goto('/') directly, you call loginPage.goto(). The page object handles navigation internally.
  • Below the beforeEach block, add the four test cases that use loginPage methods instead of inline selectors:
  test('should login with valid credentials', async ({ page }) => {
    await loginPage.login('standard_user', 'secret_sauce');
    await expect(page).toHaveURL(/inventory/);
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await loginPage.login('invalid_user', 'wrong_password');
    await expect(loginPage.errorMessage).toContainText(
      'Username and password do not match any user in this service'
    );
  });

  test('should show error for empty username', async ({ page }) => {
    await loginPage.login('', 'secret_sauce');
    await expect(loginPage.errorMessage).toContainText('Username is required');
  });

  test('should show error for empty password', async ({ page }) => {
    await loginPage.login('standard_user', '');
    await expect(loginPage.errorMessage).toContainText('Password is required');
  });
});

What does this code do?

Each test now calls loginPage.login() instead of manually filling each field and clicking the button. Error assertions reference loginPage.errorMessage instead of repeating the selector string.

  • Save tests/login.spec.js.
  • Run the login tests to confirm they still pass by running:
npx playwright test tests/login.spec.js

All 4 login tests should pass. The behavior is identical, but the code is now cleaner.

Tests failing after refactor?

  • Check that the import path is '../pages/LoginPage.js' (with the ../ prefix, since tests are in a sibling folder).
  • Make sure you have the closing }); for both the test.describe and test.beforeEach blocks.
  • Verify the class name matches exactly: LoginPage in both the import and the file.

help me debug my login test refactor

Refactor inventory tests to use page objects

The inventory tests benefit from both page objects. LoginPage handles login in beforeEach, and InventoryPage provides methods for interacting with products.

  • Open tests/inventory.spec.js and replace its entire contents with the following code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage.js';
import { InventoryPage } from '../pages/InventoryPage.js';

test.describe('Inventory Tests', () => {
  let loginPage;
  let inventoryPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    inventoryPage = new InventoryPage(page);
    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');
  });

  test('should display products title after login', async () => {
    await expect(inventoryPage.title).toHaveText('Products');
  });

  test('should add item to cart and update badge', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await expect(inventoryPage.cartBadge).toHaveText('1');
  });

  test('should add multiple items to cart', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.addItemToCart('Sauce Labs Bike Light');
    await expect(inventoryPage.cartBadge).toHaveText('2');
  });
});

What changed here?

  • The beforeEach hook now uses loginPage.goto() and loginPage.login() instead of repeating all three selector lines.
  • Tests use inventoryPage.addItemToCart('Sauce Labs Backpack') instead of manually finding and clicking the button.
  • If the login button selector ever changes, you only update LoginPage.js. Every test file picks up the fix automatically.
  • Save tests/inventory.spec.js.

Before you run the full suite, what do you expect to happen? All 7 tests should still pass with no behavior change, since you only reorganized the code.

  • Run the entire test suite by running:
npx playwright test

All 7 tests pass. The behavior is identical to before, but your selectors are now centralized in page object classes.

Some tests failing?

  • Check the import path for InventoryPage uses '../pages/InventoryPage.js' (matching the exact filename with capital letters).
  • Make sure both loginPage and inventoryPage are declared with let outside the beforeEach (at the top of the describe block).
  • The inventory test functions no longer need { page } as a parameter since they access the page through the page objects.

help me fix my inventory test refactor

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files: pages/LoginPage.js, pages/InventoryPage.js, tests/login.spec.js, and tests/inventory.spec.js.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is present in your files, in order:

  • pages/LoginPage.js has the LoginPage class with 4 locators, goto(), and login().
  • pages/InventoryPage.js has the InventoryPage class with 4 locators, addItemToCart(), and goToCart().
  • tests/login.spec.js imports LoginPage and uses it in beforeEach with all 4 test cases.
  • tests/inventory.spec.js imports both page objects and uses them in beforeEach with all 3 test cases.

Why is this better?

Count the selector strings in your code now versus before. The [data-test="username"] selector used to appear in 5 places across two files. Now it appears exactly once, inside LoginPage.js.

If SauceDemo changes that attribute tomorrow, you update one line in one file and every test keeps working.

Your test suite is now cleanly organized with the same pattern production teams use. Next up, you'll put these page objects to work in a full end-to-end purchase flow that crosses multiple pages.

Build an End-to-End Purchase Flow

Your page objects are clean, and each page's selectors live in exactly one place. But so far, every test lives on a single page.

A professional test suite needs flows that cross multiple pages, exercising the full user journey from start to finish. In this step, you will write an end-to-end purchase test that moves through login, inventory, and cart using your page objects together.

In this step, get ready to:
  • Create the CartPage page object.
  • Write a purchase flow test that crosses multiple pages.
  • Generate the HTML report and explore the Trace Viewer.
Create the CartPage page object

The purchase flow needs to verify items in the cart and proceed to checkout. You need a page object to encapsulate the cart page's selectors and actions, just like you did for Login and Inventory.

  • Create a new file called pages/CartPage.js by pasting the following code:
export class CartPage {
  constructor(page) {
    this.page = page;
    // Locator for all cart items on the page
    this.cartItems = page.locator('[data-test="inventory-item"]');
    // Locator for the checkout button
    this.checkoutButton = page.locator('[data-test="checkout"]');
  }

  // Returns an array of item name strings in the cart
  async getItemNames() {
    return this.cartItems.locator('[data-test="inventory-item-name"]').allTextContents();
  }

  // Clicks the checkout button to proceed
  async proceedToCheckout() {
    await this.checkoutButton.click();
  }
}

What does this code do?

CartPage follows the same Page Object Model pattern as your other page classes. The constructor takes a page instance and defines locators.

  • getItemNames() uses allTextContents() to grab every item name in the cart as an array of strings.
  • proceedToCheckout() clicks the checkout button, advancing the user to the next step in the purchase flow.
  • Save pages/CartPage.js.
  • Confirm the file exists by running:
ls pages/

You should see CartPage.js, InventoryPage.js, and LoginPage.js listed.

Don't see CartPage.js?

  • Make sure you created the file inside the pages/ directory, not in the project root.
  • Check the filename is exactly CartPage.js with a capital C and capital P.

Why isn't my CartPage.js file appearing in the pages directory?

Write the purchase flow test

This test will exercise the complete user journey: log in, add an item to the cart, navigate to the cart page, verify the item is there, and proceed to checkout. All three page objects work together in a single test.

  • Create a new file called tests/purchase.spec.js by pasting the following code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage.js';
import { InventoryPage } from '../pages/InventoryPage.js';
import { CartPage } from '../pages/CartPage.js';

test.describe('Purchase Flow', () => {
  test('should complete purchase from login to checkout', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const inventoryPage = new InventoryPage(page);
    const cartPage = new CartPage(page);

    // Step 1: Log in
    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');

    // Step 2: Add item to cart from inventory
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await expect(inventoryPage.cartBadge).toHaveText('1');

    // Step 3: Navigate to cart and verify item
    await inventoryPage.goToCart();
    const itemNames = await cartPage.getItemNames();
    expect(itemNames).toContain('Sauce Labs Backpack');

    // Step 4: Proceed to checkout
    await cartPage.proceedToCheckout();
    await expect(page).toHaveURL(/checkout-step-one/);
  });
});

What does this test prove?

This single test crosses three pages (login, inventory, cart) using three page objects. It proves that your Page Object Model scales beyond isolated page tests to full user journeys.

Notice how readable the test is. Each step describes a user action in plain English, and the page objects hide the selector details. Anyone reading this test can understand the flow without knowing the HTML structure.

  • Save tests/purchase.spec.js.
  • Run the purchase flow test by running:
npx playwright test tests/purchase.spec.js

You should see 1 test pass in the terminal output.

Seeing an import error?

  • Check that the import path uses '../pages/CartPage.js' with the correct relative path and .js extension.
  • Make sure CartPage.js exports the class with export class CartPage (not module.exports).

Why is my purchase flow test failing with an import error?

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the complete pages/CartPage.js:

export class CartPage {
  constructor(page) {
    this.page = page;
    this.cartItems = page.locator('[data-test="inventory-item"]');
    this.checkoutButton = page.locator('[data-test="checkout"]');
  }

  async getItemNames() {
    return this.cartItems.locator('[data-test="inventory-item-name"]').allTextContents();
  }

  async proceedToCheckout() {
    await this.checkoutButton.click();
  }
}

And here is the complete tests/purchase.spec.js:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage.js';
import { InventoryPage } from '../pages/InventoryPage.js';
import { CartPage } from '../pages/CartPage.js';

test.describe('Purchase Flow', () => {
  test('should complete purchase from login to checkout', async ({ page }) => {
    const loginPage = new LoginPage(page);
    const inventoryPage = new InventoryPage(page);
    const cartPage = new CartPage(page);

    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');

    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await expect(inventoryPage.cartBadge).toHaveText('1');

    await inventoryPage.goToCart();
    const itemNames = await cartPage.getItemNames();
    expect(itemNames).toContain('Sauce Labs Backpack');

    await cartPage.proceedToCheckout();
    await expect(page).toHaveURL(/checkout-step-one/);
  });
});
Run the full suite and generate the HTML report

Now that all your tests are in place, you will run the entire suite and explore Playwright's built-in reporting and debugging tools.

  • Before you run, predict: how many total tests do you expect to pass across all three test files?
  • Run the full test suite by running:
npx playwright test

You should see 8 tests pass (4 login + 3 inventory + 1 purchase).

  • Open the HTML test report by running:
npx playwright show-report

A browser tab opens with a dashboard showing all your tests, their status, and how long each took.

  • Click on the Purchase Flow test to see the detailed view with each test step and its timing.
  • Press Ctrl+C in the terminal to stop the report server when you are done exploring.

What is the HTML report?

Playwright's HTML Reporter generates a self-contained dashboard for your test results. You can filter by passed, failed, skipped, and flaky tests. Clicking into a test shows you each action's timing and any error details.

This report is something you can share in interviews or attach to pull requests to show your test coverage.

Now let's see what debugging a failure looks like. You will intentionally break a test to explore the Trace Viewer.

  • In tests/purchase.spec.js, find this line:
await expect(inventoryPage.cartBadge).toHaveText('1');
  • Change '1' to '2', so the line looks like this:
await expect(inventoryPage.cartBadge).toHaveText('2');
  • Save the file.
  • Run the tests with tracing enabled by running:
npx playwright test --trace on

The purchase test fails because the cart badge shows "1" but the test expects "2".

  • Open the report by running:
npx playwright show-report
  • In the report, click on the failed test.
  • Click the trace icon next to the test name to open the Trace Viewer.

The Trace Viewer shows a timeline of every action your test performed. You can see DOM snapshots before and after each action, inspect network requests, and view console logs.

What can you see in the Trace Viewer?

  • The Actions tab shows which locator was used for each action and how long it took.
  • The Before and After tabs show DOM snapshots around each action so you can see exactly what the page looked like.
  • The Network tab shows all HTTP requests made during the test.

This is how production teams debug flaky or failing tests on CI without re-running them locally.

  • Press Ctrl+C in the terminal to stop the report server.

Now fix the test back to its correct state.

  • In tests/purchase.spec.js, change '2' back to '1':
await expect(inventoryPage.cartBadge).toHaveText('1');
  • Save the file.
  • Confirm everything passes by running:
npx playwright test

All 8 tests should pass again.

Still seeing a failure?

  • Make sure you changed the expected value back to '1' and saved the file.
  • Check that no other lines were accidentally modified when you edited the assertion.

Why are my Playwright tests still failing after I reverted my change?

You now have a professional end-to-end test suite with HTML reporting and trace-based debugging. Next up, the Secret Mission challenges you to build custom fixtures and a complete checkout flow.

Secret mission

Custom Fixtures and Complete Checkout

Your test suite handles login through checkout-step-one, but a real QA framework goes further. Can you build a custom Playwright fixture that eliminates repeated login setup and write a complete checkout flow that finishes with a confirmed order?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses SauceDemo, a free public demo site. Playwright is open source. There are no ongoing costs or paid resources to worry about.

Decide whether to keep your project files on your machine, or delete them entirely.

Resources you used:

  • The playwright-saucedemo project folder on your Desktop (includes test files, page objects, and config).
  • Node modules installed inside the project folder.
  • Playwright browser binaries (Chromium) downloaded during setup.
  • The playwright-report folder generated by the HTML reporter.

Keep everything running

No action needed. Choose this if you plan to keep building on this test framework, adding more page objects or extending your test coverage.

  • Your project folder stays on your Desktop at playwright-saucedemo.
  • Playwright browser binaries remain cached on your machine. They take up disk space but cause no ongoing cost.
  • You can re-run your tests anytime with npx playwright test.

Pause - I'll come back to this later

Free up disk space from browser binaries while keeping your project code intact.

  • Remove the downloaded browser binaries by running this command inside your project folder:
npx playwright uninstall
  • Your project files, page objects, and test code all remain on disk.
  • When you come back, reinstall the browsers by running:
npx playwright install
  • Your tests will work exactly as before after reinstalling.

Delete - I don't want to use this again

Remove all project files, browser binaries, and generated reports from your machine.

  • First, remove the Playwright browser binaries by running this from inside your project folder:
npx playwright uninstall
  • Then delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf playwright-saucedemo
  • On Windows, run:
cd ~\Desktop
rmdir /s /q playwright-saucedemo
  • That removes everything: your test files, page objects, node modules, config, and reports.

Nice Work!

Nice Work!

Nice work! You just built a professional test automation framework using Playwright and the Page Object Model pattern against a real e-commerce demo site.

You've learned how to:

  • Structure a test suite with the Page Object Model pattern, centralizing selectors in reusable classes so a single change propagates across every test.
  • Write login tests covering positive and negative scenarios with web-first assertions, and experience the maintenance problem of inline selectors before solving it with POM refactoring.
  • Build an end-to-end purchase flow test crossing multiple pages, then generate HTML test reports and debug failures with the Trace Viewer.
  • Secret Mission: Created custom Playwright fixtures for pre-authenticated state and completed a full checkout flow test with order confirmation.

Ready to quiz yourself?