Level 2 · Mid-Level Automation · Practice 01

Page Object Model Refactor

Take three flat Playwright tests and refactor them into a clean Page Object Model. One page class, reused across every test.

1 Goal

By the end of this exercise you will have a working Playwright project with a single LoginPage class used by three tests against saucedemo.com — a public test site designed for automation practice. You will start with three nearly-identical copy-pasted tests, then collapse the duplicated locators and steps into one page object. The test file will shrink by roughly 60 percent, and a future selector change will need exactly one edit instead of three.

Time: about 30 minutes. Free tools only.

2 Install Node.js and Playwright

Playwright is a free, open-source browser automation library from Microsoft. It runs on Node.js (also free). You do not need any paid IDE — VS Code, WebStorm Community, or even plain Notepad works.

  1. Install Node.js 20 LTS or newer Download the LTS installer from nodejs.org/en/download. The default options are fine.
    node --version
    npm --version

    Run those two commands in PowerShell or Command Prompt. You should see something like v20.11.1 and 10.2.4. If node is not recognised, close and reopen the terminal.

    node --version
    npm --version

    Run in Terminal. If you use Homebrew, brew install node@20 works too.

    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt-get install -y nodejs
    node --version

    For Debian/Ubuntu. On Fedora/RHEL use dnf instead of apt-get.

  2. Verify npm works
    npm --version
    Should print something like 10.2.4. npm ships with Node.js, so if Node installed, npm is there.
  3. Choose an editor (optional, all free) Any of VS Code, WebStorm (free for non-commercial), or Neovim. VS Code with the Playwright extension is the easiest starting point.
Pro tip: Playwright itself is installed per-project in step 3 — you do not install it globally. This keeps versions isolated per project, which is exactly what you want.

3 Project setup

Create a new folder anywhere you like — Desktop, home directory, wherever. The commands below are identical on Windows, macOS, and Linux.

mkdir pom-refactor
cd pom-refactor
npm init -y
npm init playwright@latest -- --quiet --browser=chromium --install-deps

The last command installs Playwright and downloads the Chromium browser binary. It takes about a minute. Accept the defaults when prompted (TypeScript: No, GitHub Actions: No).

You should end up with this folder structure:

pom-refactor/ ├── node_modules/ ├── tests/ │ └── example.spec.js (delete this — we write our own) ├── tests-examples/ ├── package.json ├── package-lock.json └── playwright.config.js

Delete tests/example.spec.js and tests-examples/ — you will not need them. Then create two files by hand:

tests/pages/LoginPage.js (empty for now)
// We will fill this in during step 4.
module.exports = {};
tests/login.spec.js (empty for now)
// We will fill this in during step 4.

Your final tree should look like this:

pom-refactor/ ├── node_modules/ ├── tests/ │ ├── pages/ │ │ └── LoginPage.js │ └── login.spec.js ├── package.json ├── package-lock.json └── playwright.config.js

4 Write the tests

We will write this in two passes. First, the "bad" version: three nearly-identical tests with duplicated selectors. Then the refactored version using a page object. Copy them both — you need to see the difference.

4a. The flat version (what we are refactoring away from)

Paste this over the contents of tests/login.spec.js:

tests/login.spec.js — flat version
const { test, expect } = require('@playwright/test');

test('valid login lands on inventory page', async ({ page }) => {
  await page.goto('https://www.saucedemo.com/');
  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\.html/);
});

test('locked out user sees the correct error', async ({ page }) => {
  await page.goto('https://www.saucedemo.com/');
  await page.locator('[data-test="username"]').fill('locked_out_user');
  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('locked out');
});

test('empty credentials show a username-required error', async ({ page }) => {
  await page.goto('https://www.saucedemo.com/');
  await page.locator('[data-test="username"]').fill('');
  await page.locator('[data-test="password"]').fill('');
  await page.locator('[data-test="login-button"]').click();
  await expect(page.locator('[data-test="error"]')).toContainText('Username is required');
});

Three tests. Three copies of [data-test="username"], three copies of [data-test="password"], three copies of the URL, three copies of the click. If the dev team renames data-test="login-button" to data-testid="submit", you have three edits to make — and if you forget one, a test will pass for the wrong reason.

4b. Extract the page object

Open tests/pages/LoginPage.js and replace its contents with this:

tests/pages/LoginPage.js
class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameInput = page.locator('[data-test="username"]');
    this.passwordInput = page.locator('[data-test="password"]');
    this.submitButton  = page.locator('[data-test="login-button"]');
    this.errorBanner   = page.locator('[data-test="error"]');
  }

  async goto() {
    await this.page.goto('https://www.saucedemo.com/');
  }

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

  async errorText() {
    return this.errorBanner.textContent();
  }
}

module.exports = { LoginPage };

The constructor grabs every selector we care about. goto() owns the URL. login() is the user-flow verb. errorText() exposes state without leaking the selector.

4c. Rewrite the tests

Replace tests/login.spec.js with the refactored version:

tests/login.spec.js — refactored
const { test, expect } = require('@playwright/test');
const { LoginPage } = require('./pages/LoginPage');

test.describe('Saucedemo login', () => {
  let loginPage;

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

  test('valid login lands on inventory page', async ({ page }) => {
    await loginPage.login('standard_user', 'secret_sauce');
    await expect(page).toHaveURL(/inventory\.html/);
  });

  test('locked out user sees the correct error', async () => {
    await loginPage.login('locked_out_user', 'secret_sauce');
    expect(await loginPage.errorText()).toContain('locked out');
  });

  test('empty credentials show a username-required error', async () => {
    await loginPage.login('', '');
    expect(await loginPage.errorText()).toContain('Username is required');
  });
});

Notice what disappeared from the test file: no raw selectors, no URL strings, no .fill() / .click() plumbing. The tests now read like the test-case names in a spreadsheet.

The beforeEach hook builds a fresh LoginPage per test and navigates to the URL, so every test starts from the same state. Any test-specific behaviour stays in the test body.

Pro tip: Page object methods should be verbs that match what a human user would do. login(), addToCart(), openMenu() — not clickLoginButton() or fillUsernameAndPasswordThenSubmit().

5 Run & verify

From the project root, run:

npx playwright test

Expected output (the timing will differ):

Running 3 tests using 3 workers

  3 passed (4.1s)

To open last HTML report run:

  npx playwright show-report

Three passing tests, no selectors duplicated anywhere. For a visual walk-through, run:

npx playwright test --headed

This launches Chromium and lets you watch each test drive the page. Add --slow-mo=300 if you want it slowed down.

To see the HTML report with screenshots and a timeline, run:

npx playwright show-report
What passing looks like: three green ticks, no console.log noise, no warnings about deprecated APIs. If you see a red cross, skip to Troubleshooting below.

6 Troubleshooting

Error: "Cannot find module './pages/LoginPage'"

Node.js resolves relative paths from the test file's location. Your login.spec.js lives in tests/, so ./pages/LoginPage correctly points to tests/pages/LoginPage.js. If you moved the spec into a subfolder, adjust the path accordingly (e.g. ../pages/LoginPage).

Also check the filename capitalisation — LoginPage.js and loginpage.js are different files on Linux and macOS even if Windows does not care.

Error: "browserType.launch: Executable doesn't exist"

Playwright downloads the browser binary separately from the npm package. Run:

npx playwright install chromium

On Linux you may also need the system libraries:

npx playwright install-deps chromium
All three tests fail with "Timeout waiting for [data-test="username"]"

Three likely causes, in order of likelihood:

  1. No internet. saucedemo.com is hosted publicly — your machine must reach it. Try curl -I https://www.saucedemo.com/.
  2. The site renamed its selectors. Open the page in a browser, right-click the username field, pick Inspect, and confirm data-test="username" still exists. If not, update the locator in LoginPage.js — note you only have to change it in one place now.
  3. Corporate proxy blocking headless browsers. Try adding --headed — if that works, your proxy is stripping the default Playwright user-agent.
Tests pass but I see "Page closed" warnings

Harmless in this exercise. It happens when a test finishes before Playwright has fully torn down the page context. If the warnings bother you, set timeout: 30000 in playwright.config.js to give teardown a bit more breathing room.

7 Challenge

Extend the page object

Easy: Add a fourth test for the problem_user account (same password, secret_sauce). It should successfully log in. You should need exactly one new line in the test file to cover it — that's the point of POM.

Harder: Create a second page object, InventoryPage.js, with a locator for [data-test="inventory-item"] and a method itemCount() that returns the number of products on screen. Then add a fifth test that logs in as standard_user and asserts the inventory page has exactly 6 items. Make LoginPage.login() return an instance of InventoryPage on success so the test reads like const inv = await loginPage.login(...); expect(await inv.itemCount()).toBe(6);