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.
-
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.1and10.2.4. Ifnodeis not recognised, close and reopen the terminal.node --version npm --version
Run in Terminal. If you use Homebrew,
brew install node@20works 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
dnfinstead ofapt-get. -
Verify npm works
Should print something like
npm --version
10.2.4. npm ships with Node.js, so if Node installed, npm is there. - 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.
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:
Delete tests/example.spec.js and tests-examples/ — you will not need them. Then create two files by hand:
// We will fill this in during step 4.
module.exports = {};// We will fill this in during step 4.
Your final tree should look like this:
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:
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:
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:
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.
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
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:
- No internet.
saucedemo.comis hosted publicly — your machine must reach it. Trycurl -I https://www.saucedemo.com/. - 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 inLoginPage.js— note you only have to change it in one place now. - 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);