Build an E2E Test Suite with Playwright

Automate login, cart, and checkout tests using Playwright and TypeScript.

Introduction

30 Second Summary

Every time a developer pushes new code, someone has to check that nothing broke. Doing that manually across three different browsers is slow, repetitive, and error-prone. Automated testing lets a script do the checking for you in seconds.

In this project, you will build a professional end-to-end test suite using Playwright and TypeScript that automatically tests login, product browsing, cart operations, and checkout on a real e-commerce website.

What You'll Build

A professional-grade automated test suite for SauceDemo (a public e-commerce demo site), structured using the Page Object Model design pattern and running across Chromium, Firefox, and WebKit.

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

  • A Playwright test suite with 8+ automated tests covering login, product catalog, shopping cart, and checkout flows.
  • A Page Object Model architecture that demonstrates professional test structure to interviewers.
  • HTML test reports and trace recordings proving cross-browser coverage across Chromium, Firefox, and WebKit.
  • Secret Mission: Add product sorting validation tests that verify all sort options work correctly across all browsers.

Are there any prerequisites?

You need basic programming knowledge (variables, functions, loops). No prior TypeScript or test automation experience is required. If you have a Java or manual QA background, you will find the patterns familiar.

Install Node.js and VS Code

Playwright is a Node.js library, which means it needs Node.js installed on your machine to run. Think of Node.js as the engine that powers Playwright behind the scenes.

In this step, you will install Node.js, set up VS Code as your code editor, and create the project folder where your test suite will live.

In this step, get ready to:
  • Install Node.js v24.16.0 LTS and verify it works.
  • Install VS Code with the Playwright Test extension.
  • Create your project folder and confirm everything is connected.
Download and install Node.js

Node.js is a JavaScript runtime that lets you execute code outside of a web browser. Playwright needs it to launch browsers, run your tests, and generate reports.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Node.js is already installed by running this command:
node --version

✔️ I see v24 or higher

Node.js is ready to go. Verify that npm (Node Package Manager) is also working by running this command:

npm --version

You should see a version number appear. npm comes bundled with Node.js and is how you will install Playwright later.

ⓧ I see an older version

You have an older version of Node.js that may not be compatible with Playwright. You need version v24 or higher.

  • Open your browser and go to the Node.js download page:
  • https://nodejs.org/en/download
  • Click the LTS tab to make sure you are downloading the Long Term Support version.
  • Select macOS as your platform and ARM64 as your architecture (this matches your M3 chip).
  • Download the .pkg installer file.
  • Open the downloaded .pkg file and follow the installer prompts. Accept the defaults at each step.
  • Once the installer finishes, close your Terminal and open a new one.
  • Verify the installation by running:
node --version

You should see v24.16.0 (or a similar v24.x.x number). Also confirm npm is working:

npm --version

You should see a version number appear.

ⓧ Command not found

Node.js is not installed yet. Let's get it set up.

  • Open your browser and go to the Node.js download page:
  • https://nodejs.org/en/download
  • Click the LTS tab to make sure you are downloading the Long Term Support version.
  • Select macOS as your platform and ARM64 as your architecture (this matches your M3 chip).
  • Download the .pkg installer file.
  • Open the downloaded .pkg file and follow the installer prompts. Accept the defaults at each step.
  • Once the installer finishes, close your Terminal and open a new one.
  • Verify the installation by running:
node --version

You should see v24.16.0 (or a similar v24.x.x number). Also confirm npm is working:

npm --version

You should see a version number appear.

What is Node.js and why do we need it?

Node.js is a JavaScript runtime that lets you run JavaScript code outside of a web browser. Playwright is built with Node.js, so it needs Node.js to run.

Think of it this way: Java needs the JVM to run, and Playwright needs Node.js to run. npm (Node Package Manager) comes bundled with Node.js and is how you install libraries like Playwright. LTS means "Long Term Support," which is the stable version recommended for production use.

Download and install VS Code

VS Code is the code editor where you will write your test files, run commands, and manage your project. It also has an integrated terminal so you can do everything from one window.

  • Open your browser and go to the VS Code download page:
  • https://code.visualstudio.com/download
  • Under the Mac section, click the Apple silicon .dmg download link.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app to the Applications folder.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

VS Code opens with a Welcome tab. Now install the Playwright extension that gives you test running and debugging tools inside the editor.

  • Click the Extensions icon in the left sidebar (it looks like four small squares).
  • Type Playwright Test for VS Code into the search bar at the top of the Extensions panel.
  • Find the extension published by Microsoft (extension ID: ms-playwright.playwright).
  • Click Install.

What does this extension do?

The Playwright Test for VS Code extension adds a testing sidebar where you can run individual tests, see pass/fail results inline, and debug tests with breakpoints. It turns VS Code into a full test automation IDE.

Don't see the extension?

Make sure you are searching in the Extensions panel (left sidebar icon with four squares), not the Command Palette. The extension ID is ms-playwright.playwright if you need to search by exact ID.

help me find and install the Playwright Test extension in VS Code

Create your project folder and verify your setup

With Node.js and VS Code ready, it is time to create the folder that will hold all your test files. You will do this from VS Code's integrated terminal so everything stays in one place.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, located above Tab on your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir playwright-saucedemo
cd playwright-saucedemo
  • Confirm Node.js is accessible from VS Code's terminal by running:
node --version

You should see v24.16.0 printed in the terminal. This confirms Node.js is properly installed and VS Code can access it.

  • Confirm the folder exists by running:
ls

The folder is empty right now, which is expected. You will fill it with Playwright project files in the next step.

Seeing 'command not found' for node?

If you installed Node.js while VS Code was already open, the terminal may not have picked up the change. Close the terminal panel (click the trash icon) and reopen it with Ctrl+`. Then try node --version again.

If it still doesn't work, try closing VS Code entirely and reopening it.

help me fix 'node command not found' in VS Code's integrated terminal

Your development environment is ready. Next up, you will initialize a Playwright project inside this folder and explore the files it creates.

Initialize Playwright Project

With Node.js and VS Code ready, it's time to scaffold your Playwright project.

Playwright provides a single command that creates your entire project structure, downloads browser engines, and generates a working example test. In this step, you'll run that command, explore what it creates, and confirm everything works by running your first automated tests.

In this step, get ready to:
  • Initialize a Playwright project with TypeScript using the setup wizard.
  • Explore the generated scaffold files and understand what each one does.
  • Run the example tests and view the HTML report.
Run the Playwright initialization wizard

Playwright has an interactive setup wizard that creates your project structure, installs dependencies, and downloads browser binaries in one command. The wizard will ask you four questions about how to configure your project.

  • Initialize your Playwright project by running this command in VS Code's integrated terminal:
npm init playwright@latest

What prompts will I see?

The wizard asks you four questions. Answer them as follows:

  • TypeScript or JavaScript: choose TypeScript (the default).
  • Tests folder name: accept the default tests by pressing Enter.
  • Add a GitHub Actions workflow: type false or select No. You won't need CI for this project.
  • Install Playwright browsers: accept the default Yes. This downloads Chromium, Firefox, and WebKit.

The browser download may take a minute or two depending on your internet speed.

  • Wait for the installation to finish. You should see a success message in the terminal once all browsers are downloaded.

Seeing a permission error?

If you see a permission error during the browser download, try closing and reopening VS Code, then run the command again. Make sure you're running the command from inside your project folder.

help me fix a permission error when running npm init playwright

Explore the generated scaffold

The wizard just created several files in your project folder. Each file has a specific job in your test suite.

  • In VS Code's Explorer sidebar on the left, expand your project folder to see the new files.
  • Click on playwright.config.ts to open it.
  • Click on package.json to open it in a new tab.
  • Expand the tests folder and click on example.spec.ts to open it.

What does each file do?

  • playwright.config.ts: The brain of your test suite. Controls which browsers to test, timeouts, retries, and reporting. Similar to how testng.xml works in Java/TestNG.
  • package.json: Lists your project's dependencies (libraries it needs). Like a pom.xml in Maven.
  • tests/example.spec.ts: A sample test file. The .spec.ts extension is a convention meaning "specification" (test file).
  • node_modules/: Where npm stores all downloaded libraries. Never edit this manually.

Take a moment to scan playwright.config.ts. You'll see the three browser projects (chromium, firefox, webkit) already configured.

What is the projects array in the config?

The projects array tells Playwright to run your tests in multiple browsers automatically. Each project uses a device profile (like Desktop Chrome or Desktop Safari) that sets viewport size and user agent.

When you run npx playwright test, Playwright runs every test file against all three browsers in parallel. One command, three browsers.

Run the example tests and view the report

The scaffold includes a working example test. Running it confirms that Playwright, the browsers, and your configuration all work together.

  • Run the example tests across all three browsers by running this command:
npx playwright test

What does this command do?

When you run npx playwright test, Playwright:

  • Reads your playwright.config.ts to know which browsers to use.
  • Finds all .spec.ts files in your tests directory.
  • Launches browser instances in headless mode (no visible window).
  • Runs each test in an isolated browser context (like a fresh incognito window).
  • Collects results and generates a report.

Each test gets a completely fresh browser state. No cookies, no cache, no leftover data from previous tests. This is called test isolation and it prevents flaky tests.

  • Confirm that your terminal shows all tests passing. You should see green checkmarks next to each test for chromium, firefox, and webkit.
  • Open the HTML test report by running this command:
npx playwright show-report

Your browser opens with an interactive dashboard showing test results broken down by browser.

What am I seeing in the report?

The HTML report is a filterable dashboard showing every test's status (passed, failed, skipped) grouped by browser. Click any test to see its execution steps, timing, and any error details.

This report auto-opens when tests fail. Since all tests passed, you had to open it manually with npx playwright show-report.

Tests failing or report not opening?

If tests fail, check your internet connection. The example test navigates to a live website, so it needs network access.

If the report doesn't open in your browser, copy the URL shown in the terminal (usually http://localhost:9323) and paste it into your browser manually.

help me debug my Playwright example test failures

✔️ Awesome, I've got everything!

Great. Double check you've seen all tests pass across Chromium, Firefox, and WebKit in the HTML report.

ⓧ I'd like to double check the full code

Here is what your playwright.config.ts should look like at this point:

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: {
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

Your package.json should contain @playwright/test in its devDependencies:

{
  "name": "playwright-project",
  "version": "1.0.0",
  "devDependencies": {
    "@playwright/test": "^1.60.0",
    "@types/node": "^20.0.0"
  }
}

Your Playwright project is scaffolded and confirmed working across three browser engines. Next up, you'll learn the TypeScript syntax you need to write professional test automation code.

Learn TypeScript Essentials for Test Automation

Your Playwright project is set up and the example tests are passing across all three browsers. Before you start writing real tests, you need to understand the TypeScript syntax that Playwright uses.

TypeScript is JavaScript with type safety added. If you have a Java background, typed languages will feel familiar. In this step, you will learn the specific TypeScript patterns that appear in every Playwright test file.

In this step, get ready to:
  • Learn TypeScript syntax: imports, async/await, type annotations, and readonly.
  • Understand TypeScript classes and how they compare to Java classes.
  • Learn Playwright's test structure with test.describe, test, and test.beforeEach.
Learn TypeScript syntax for Playwright

Every Playwright test file uses a small set of TypeScript features. You do not need to learn the entire language. Four patterns cover almost everything you will write.

What TypeScript syntax do I need to know?

If you know Java, here is the mental translation for the TypeScript you will see in Playwright:

  • import/export: Like Java's import com.example.Page. TypeScript uses destructured imports: import { test, expect } from '@playwright/test'.
  • async/await: Replaces Thread.sleep and explicit waits. Any browser action needs await to pause until it completes. Any function that uses await must be marked async.
  • Type annotations: : string, : number, : Page. Same concept as Java types but placed after the variable name instead of before it.
  • readonly: Equivalent to Java's private final. Prevents reassignment after the constructor sets the value.

Now try writing these patterns yourself.

  • Create a temporary scratch file in your project by running this command in VS Code's integrated terminal:
touch scratch.ts
  • Open scratch.ts in the editor and paste this practice code:
// Import a type from Playwright
import { type Page } from '@playwright/test';

// Type annotations tell TypeScript what kind of value a variable holds
const siteName: string = 'SauceDemo';
const productCount: number = 6;

// async/await: any function that talks to the browser needs these
async function visitHomepage(page: Page): Promise<void> {
  await page.goto('/');
  console.log(`Navigated to ${siteName} with ${productCount} products`);
}

What does this code do?

  • import { type Page } brings in Playwright's Page type so TypeScript knows what a page object looks like.
  • const siteName: string declares a variable that can only hold text. const means it cannot be reassigned (like Java's final).
  • async marks the function as asynchronous. await pauses execution until page.goto('/') finishes loading the page.

Why async/await instead of Thread.sleep?

In Java/Selenium, you use explicit waits or Thread.sleep to handle timing. Playwright replaces all of that with await.

When you write await page.goto('/'), Playwright automatically waits until the page finishes loading before moving to the next line. If you forget await, your test tries to interact with elements before the browser is ready and fails.

You will not run this file because it is just for understanding the syntax patterns. The important thing is recognizing these four features when you see them in test code.

Understand TypeScript classes

Playwright's Page Object Model uses TypeScript classes to organize locators and actions. If you have written Java classes before, TypeScript classes will look almost identical.

How do TypeScript classes compare to Java?

A TypeScript class has the same building blocks as a Java class: a constructor, properties, and methods. The syntax is lighter because TypeScript infers more and needs fewer keywords.

  • Java: private final WebDriver driver; becomes TypeScript: readonly page: Page;.
  • Java: public LoginPage(WebDriver driver) becomes TypeScript: constructor(page: Page).
  • Java: public void login(String user) becomes TypeScript: async login(user: string).
  • No access modifiers needed. TypeScript properties are public by default.

What does a Page Object class look like?

Here is the pattern you will use in Step 7 when you build your Page Objects:

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

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByPlaceholder('Username');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
  }
}

readonly page: Page stores the browser page passed into the constructor. Each Locator property defines where to find an element on the page. Methods like login() bundle multiple actions into one reusable call.

You do not need to write this class yet. The goal is to recognize the pattern so it is familiar when you build it later.

Learn Playwright's test structure

Every Playwright test file uses three building blocks to organize tests. Understanding this structure now makes writing real tests in the next steps much faster.

What are test.describe, test, and test.beforeEach?

  • test.describe('Group Name', () => { ... }) groups related tests together. Like a Java test class.
  • test('test name', async ({ page }) => { ... }) defines a single test case. Like a @Test method in JUnit.
  • test.beforeEach(async ({ page }) => { ... }) runs setup code before every test in the group. Like @BeforeEach in JUnit.

The { page } parameter is called a fixture. Playwright automatically creates a fresh browser page for each test and passes it in. You do not need to create or close browsers manually.

Now write a skeleton test file to see this structure in action.

  • Open scratch.ts in VS Code and replace its contents with this skeleton test:
import { test, expect } from '@playwright/test';

test.describe('Login Tests', () => {
  // Runs before EACH test in this group
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  // One test case following Arrange-Act-Assert
  test('should login successfully', async ({ page }) => {
    // Arrange: page is already loaded by beforeEach
    // Act: perform the login action
    await page.getByPlaceholder('Username').fill('standard_user');
    await page.getByPlaceholder('Password').fill('secret_sauce');
    await page.getByRole('button', { name: 'Login' }).click();
    // Assert: verify the result
    await expect(page).toHaveURL(/inventory/);
  });
});

What does this test do?

  • test.describe('Login Tests', () => { ... }) creates a group called "Login Tests" that will appear in your HTML report.
  • test.beforeEach navigates to the home page before every test runs. This avoids repeating the same setup line in each test.
  • The test uses the Arrange-Act-Assert pattern: preconditions are set, an action is performed, then the outcome is verified.
  • await expect(page).toHaveURL(/inventory/) asserts that after login, the URL contains "inventory". The regex syntax /inventory/ means "contains this text anywhere in the URL".
  • Save scratch.ts.

This file is only for learning. You will not run it as a test because it is not inside the tests/ folder and Playwright only picks up files from the configured test directory.

  • Delete the scratch file now that you have seen the patterns, by running:
rm scratch.ts
  • Confirm the file is gone by running:
ls scratch.ts

You should see an error message indicating the file does not exist. That confirms cleanup is complete.

Still see scratch.ts listed?

Make sure you ran rm scratch.ts from the same directory where you created the file (your project root folder). Check your current directory with pwd to confirm you are in the right place.

Help me delete my scratch file in the right directory.

Key patterns to remember

Every Playwright test file you write in this project will use these exact patterns:

  • Imports at the top bring in test, expect, and your Page Object classes.
  • test.describe groups related test cases.
  • test.beforeEach handles repeated setup like logging in.
  • Every browser interaction uses await.

You now have the TypeScript vocabulary to read and write Playwright tests. Next up, you will learn how Playwright finds elements on a page and how its assertions work.

Configure Your Project for SauceDemo

You now understand how Playwright locators find elements and how assertions verify page state. Next, you'll point your test suite at a real target application.

In this step, you'll configure Playwright to test against SauceDemo, a public e-commerce demo site built for practicing test automation. You'll also set up the file structure that organizes your tests by feature.

In this step, get ready to:
  • Configure playwright.config.ts with the SauceDemo base URL.
  • Create dedicated test files for login, inventory, cart, and checkout.
  • Verify Playwright recognizes your new file structure.
Add the base URL to your config

Your playwright.config.ts already has the HTML reporter and trace collection configured from the scaffold. The one piece missing is a baseURL that tells Playwright which website your tests target.

What is SauceDemo?

SauceDemo (saucedemo.com) is a demo e-commerce site maintained by Sauce Labs, a major testing company. It's purpose-built for practicing test automation.

It has 6 user types (including locked-out and error users), a full shopping flow from login to checkout, and stable data-test attributes for reliable locators. All users share the password secret_sauce.

  • In VS Code, open playwright.config.ts from the Explorer sidebar.
  • Find the use block. It currently looks like this:
  use: {
    trace: 'on-first-retry',
  },
  • Add the baseURL line above the trace line, so the block now looks like this:
  use: {
    baseURL: 'https://www.saucedemo.com',
    trace: 'on-first-retry',
  },

Why set a baseURL?

With baseURL configured, you write page.goto('/') instead of page.goto('https://www.saucedemo.com/') in every test.

This makes your tests portable. If you ever need to test against a different environment (staging, localhost), you change the URL in one place.

  • Save playwright.config.ts.
Delete the example test and create your test files

The scaffold's tests/example.spec.ts demonstrates Playwright's features but isn't relevant to your SauceDemo project. You'll replace it with four dedicated test files, one for each feature area of the application.

  • Delete the example file and create your test file structure by running these commands in the VS Code terminal:
rm tests/example.spec.ts
touch tests/login.spec.ts tests/inventory.spec.ts tests/cart.spec.ts tests/checkout.spec.ts

Why separate test files per feature?

Organizing tests by feature (login, inventory, cart, checkout) mirrors how manual QA test plans are structured. Each file focuses on one area of the application, making it easy to run targeted tests during development.

  • Confirm the files exist by running:
ls tests/
  • You should see login.spec.ts, inventory.spec.ts, cart.spec.ts, and checkout.spec.ts in the output.

Don't see the files?

Make sure your terminal is in your playwright-project folder. Run pwd to check your current directory.

If the rm command gives an error, verify the file path matches exactly: tests/example.spec.ts.

help me troubleshoot my file creation

Confirm Playwright finds your test files

Even though your test files are empty, Playwright should still detect them in the tests/ directory. Running the test command now confirms your config and file structure are wired up correctly.

  • Run the test suite by running this command:
npx playwright test
  • You should see a message saying no tests found. This is expected because your test files don't contain any test() calls yet.

Seeing an error instead of 'no tests found'?

If you see a syntax error, make sure you saved playwright.config.ts after adding the baseURL line. Check that the line ends with a comma and the quotes match.

If the command is not recognized, confirm node --version works in the same terminal. The npx command comes bundled with Node.js.

help me debug my Playwright config

✔️ Awesome, I've got everything!

Great. Double check you've saved playwright.config.ts and you're ready to write your first real tests.

ⓧ I'd like to double check the full code

Here's what your playwright.config.ts should look like at this point:

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'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

Your project is now configured and ready for SauceDemo. Next up, you'll write your first real login tests using the locator strategies you learned earlier.

Write Your First Login Tests

Your project is configured and pointing at SauceDemo. The empty test files are ready and waiting.

Now it's time to write actual automated tests. You'll start with login tests because login is the gateway to every other feature. If login breaks, nothing else can be tested.

In this step, get ready to:
  • Write a successful login test using Playwright locators.
  • Write negative login tests for invalid and locked-out users.
  • Run your tests in headed mode and view the HTML report.
Write a successful login test

Every test suite starts with the happy path. This is the normal, expected flow that works correctly. For a login page, the happy path is: enter valid credentials, click login, land on the products page.

  • Open tests/login.spec.ts in VS Code by clicking on it in the Explorer sidebar.
  • Delete the // Empty test file comment and replace the entire file with this code:
import { test, expect } from '@playwright/test';

test.describe('Login Tests', () => {
  // Navigate to the login page before each test
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('successful login with standard user', async ({ page }) => {
    // Fill in credentials using placeholder text as locators
    await page.getByPlaceholder('Username').fill('standard_user');
    await page.getByPlaceholder('Password').fill('secret_sauce');
    // Click the login button by its role and visible name
    await page.getByRole('button', { name: 'Login' }).click();
    // Assert the URL changed to the inventory page
    await expect(page).toHaveURL(/inventory/);
  });
});

What does this code do?

  • import { test, expect } brings in Playwright's test runner and assertion library.
  • test.describe() groups related tests together. Think of it like a test class in Java.
  • test.beforeEach() runs before every test. Here it navigates to / which Playwright combines with the baseURL from your config.
  • getByPlaceholder('Username') finds the input field by its placeholder text. This is a user-facing locator that stays stable even if developers change CSS classes.
  • await expect(page).toHaveURL(/inventory/) is a web-first assertion. It auto-retries until the URL matches the pattern or the timeout expires.

Why use async/await on every line?

Every Playwright action that touches the browser (navigating, clicking, filling, asserting) takes time. The await keyword tells your code to pause and wait for that action to complete before moving to the next line.

If you forget await, your test will try to click a button before the page has loaded. The test will fail with confusing timing errors.

  • Save tests/login.spec.ts.
  • Verify your first test works by running this command in VS Code's integrated terminal:
npx playwright test tests/login.spec.ts --project=chromium

You should see output showing 1 test passed in the terminal.

Test failing or not found?

  • Make sure you saved the file. Unsaved changes show a dot on the tab name in VS Code.
  • Check that your file is named exactly login.spec.ts inside the tests/ folder. Playwright only finds files matching .spec.ts.
  • If you see a syntax error, make sure you have the closing }); for both the test.describe and test.beforeEach blocks.

Help me debug my first Playwright test

Write negative login tests

A thorough test suite doesn't only check that things work. It also checks that things fail correctly. In manual QA, you'd test invalid credentials and locked accounts. Now you'll automate those same checks.

What is negative testing?

Negative testing verifies that the system handles invalid input gracefully. Instead of crashing or showing a blank screen, it should display a clear error message. For login, this means testing wrong passwords and locked accounts.

  • In tests/login.spec.ts, find the closing }); of the successful login test (the one with toHaveURL).
  • Add this invalid credentials test directly below it, inside the test.describe block:
  test('failed login with invalid credentials', async ({ page }) => {
    // Attempt login with credentials that don't exist
    await page.getByPlaceholder('Username').fill('invalid_user');
    await page.getByPlaceholder('Password').fill('wrong_password');
    await page.getByRole('button', { name: 'Login' }).click();
    // Assert the error message appears with expected text
    await expect(page.locator('[data-test="error"]')).toContainText('Username and password do not match any user in this service');
  });

What does this code do?

  • This test uses fake credentials (invalid_user / wrong_password) to trigger a login failure.
  • page.locator('[data-test="error"]') uses a CSS selector targeting SauceDemo's data-test attribute. This is how SauceDemo marks elements specifically for test automation.
  • toContainText() checks that the error element contains the expected message. It uses partial matching, so the full error can include a prefix and still pass.
  • Now add the locked-out user test directly below the invalid credentials test, still inside the test.describe block:
  test('locked out user cannot login', async ({ page }) => {
    // Attempt login with the locked out user account
    await page.getByPlaceholder('Username').fill('locked_out_user');
    await page.getByPlaceholder('Password').fill('secret_sauce');
    await page.getByRole('button', { name: 'Login' }).click();
    // Assert the lockout-specific error message appears
    await expect(page.locator('[data-test="error"]')).toContainText('Sorry, this user has been locked out');
  });

Why test the locked-out user separately?

The locked-out user uses the correct password (secret_sauce) but still gets rejected. This is a different failure mode from invalid credentials. A good test suite verifies that each type of error produces its own distinct, helpful message.

  • Save tests/login.spec.ts.

You won't run these tests just yet. The next substep runs all three login tests together in headed mode so you can watch them execute.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete tests/login.spec.ts file. Make sure yours matches this exactly:

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

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

  test('successful login with standard user', async ({ page }) => {
    await page.getByPlaceholder('Username').fill('standard_user');
    await page.getByPlaceholder('Password').fill('secret_sauce');
    await page.getByRole('button', { name: 'Login' }).click();
    await expect(page).toHaveURL(/inventory/);
  });

  test('failed login with invalid credentials', async ({ page }) => {
    await page.getByPlaceholder('Username').fill('invalid_user');
    await page.getByPlaceholder('Password').fill('wrong_password');
    await page.getByRole('button', { name: 'Login' }).click();
    await expect(page.locator('[data-test="error"]')).toContainText('Username and password do not match any user in this service');
  });

  test('locked out user cannot login', async ({ page }) => {
    await page.getByPlaceholder('Username').fill('locked_out_user');
    await page.getByPlaceholder('Password').fill('secret_sauce');
    await page.getByRole('button', { name: 'Login' }).click();
    await expect(page.locator('[data-test="error"]')).toContainText('Sorry, this user has been locked out');
  });
});
Run your login tests in headed mode

So far, Playwright has been running tests in headless mode, meaning no browser window is visible. The --headed flag opens a real browser window so you can watch your tests click through the login page.

  • Run all three login tests in headed mode by running this command:
npx playwright test tests/login.spec.ts --headed

You should see browser windows flash open, navigate to SauceDemo, fill in credentials, and close. The terminal shows 3 tests passed across all 3 browsers (9 total test runs).

Why 9 test runs for 3 tests?

Your config defines 3 browser projects: Chromium, Firefox, and WebKit. Playwright runs every test against every browser. So 3 tests multiplied by 3 browsers equals 9 total test executions.

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

A browser tab opens showing your HTML test report. You should see all 9 test runs listed with green checkmarks.

What am I looking at in the HTML report?

The report shows each test grouped by browser project. You can click on any test to see its execution timeline, including how long each step took. This report is what you'd show in an interview to demonstrate your test coverage.

Tests failing or browser not opening?

  • If you see a timeout error, check your internet connection. Playwright needs to reach https://www.saucedemo.com.
  • If the headed browser opens but the test still fails, make sure the assertions match exactly. The error text assertions use toContainText (partial match), not toHaveText (exact match).
  • If the report command does nothing, make sure you ran npx playwright test first. The report is generated after a test run completes.

Help me debug my Playwright login tests

You now have 3 automated login tests running across 3 browsers. Next up, you'll organize these tests using the Page Object Model pattern, the number one design pattern interviewers ask about.

Build Page Object Models

Your login tests work, but look at all those repeated locators. Every test calls page.getByPlaceholder('Username') and page.getByPlaceholder('Password') directly. If a developer changes the placeholder text tomorrow, you would have to update every single test that touches the login form.

The Page Object Model design pattern solves this by defining all locators and actions for a page in one class. You change the locator once, and every test that uses it keeps working. In this step, you will build page objects for the Login and Inventory pages, then refactor your tests to use them.

In this step, get ready to:
  • Create a LoginPage class with reusable locators and methods.
  • Create an InventoryPage class for product and cart interactions.
  • Refactor your login tests to use the Page Object pattern.
Create the LoginPage class

The LoginPage class will hold every locator and action related to the SauceDemo login screen. Once it exists, your tests will call loginPage.login() instead of repeating three lines of fill-and-click code.

  • Create a pages directory inside your project folder by running this command in VS Code's integrated terminal:
mkdir pages
  • Confirm the directory was created by running:
ls

You should see pages listed alongside tests and node_modules in the output.

  • In VS Code's Explorer sidebar, right-click the pages folder.
  • Click New File.
  • Name the file login.page.ts and press Enter.
  • Paste the following code into login.page.ts:
import { type Locator, type Page, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByPlaceholder('Username');
    this.passwordInput = page.getByPlaceholder('Password');
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.errorMessage = page.locator('[data-test="error"]');
  }

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

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

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

What does this code do?

This class follows the exact same pattern as a Java Page Object. The constructor receives a Page instance (like passing a WebDriver) and defines all locators once.

  • readonly properties are the TypeScript equivalent of Java's private final. They cannot be reassigned after construction.
  • goto() navigates to the login page using the baseURL from your config.
  • login() encapsulates the three steps every login test repeats: fill username, fill password, click button.
  • expectError() wraps the assertion so tests can verify error messages without knowing the locator.
  • Save pages/login.page.ts.

Seeing red squiggles in VS Code?

If VS Code shows errors on the imports, make sure the file is saved inside the pages folder (not tests). Also confirm the filename ends in .ts, not .js.

help me fix TypeScript import errors in my Playwright page object file

Create the InventoryPage class

The inventory page is where users browse products, add items to their cart, and sort the catalog. This page object will be reused across multiple test files in later steps.

  • Right-click the pages folder in the Explorer sidebar.
  • Click New File and name it inventory.page.ts.
  • Paste the following code into inventory.page.ts:
import { type Locator, type Page, expect } from '@playwright/test';

export class InventoryPage {
  readonly page: Page;
  readonly inventoryItems: Locator;
  readonly sortDropdown: Locator;
  readonly cartBadge: Locator;
  readonly cartLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.inventoryItems = page.locator('[data-test="inventory-item"]');
    this.sortDropdown = page.locator('[data-test="product-sort-container"]');
    this.cartBadge = page.locator('[data-test="shopping-cart-badge"]');
    this.cartLink = page.locator('[data-test="shopping-cart-link"]');
  }

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

  async removeItemFromCart(itemName: string) {
    const item = this.inventoryItems.filter({ hasText: itemName });
    await item.getByRole('button', { name: 'Remove' }).click();
  }

  async getCartBadgeCount(): Promise<number> {
    const text = await this.cartBadge.textContent();
    return Number(text);
  }

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

  async sortBy(option: string) {
    await this.sortDropdown.selectOption(option);
  }

  async getProductNames(): Promise<string[]> {
    const names = this.page.locator('[data-test="inventory-item-name"]');
    return await names.allTextContents();
  }

  async getProductPrices(): Promise<number[]> {
    const prices = this.page.locator('[data-test="inventory-item-price"]');
    const texts = await prices.allTextContents();
    return texts.map(text => parseFloat(text.replace('$', '')));
  }
}

What does this code do?

This class handles everything on the product catalog page. It uses the data-test attributes that SauceDemo provides specifically for test automation.

  • addItemToCart() uses the .filter({ hasText }) pattern to narrow down to a specific product card, then clicks the Add to cart button within that card.
  • getCartBadgeCount() reads the number displayed on the shopping cart icon so tests can verify items were added.
  • getProductNames() and getProductPrices() use allTextContents() to extract every visible product name or price as an array. You will use these for sort validation later.
  • Save pages/inventory.page.ts.

You will not see a visible change yet. The next substep connects these page objects to your tests.

VS Code not recognizing the Page type?

Check that your import statement at the top of the file reads exactly import { type Locator, type Page, expect } from '@playwright/test'. The type keyword tells TypeScript these are type-only imports.

help me fix type import errors in my Playwright page object

Refactor your login tests to use LoginPage

Now for the payoff. You will replace every inline locator in tests/login.spec.ts with calls to your new LoginPage class. The tests will do the same thing, but the code becomes dramatically easier to read and maintain.

  • Open tests/login.spec.ts in VS Code.
  • Select all the code in the file (Cmd+A on macOS or Ctrl+A on Windows).
  • Replace it with the following refactored version:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

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

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

  test('successful login with standard user', async ({ page }) => {
    await loginPage.login('standard_user', 'secret_sauce');
    await expect(page).toHaveURL(/inventory/);
  });

  test('failed login with invalid credentials', async () => {
    await loginPage.login('invalid_user', 'wrong_password');
    await loginPage.expectError('Username and password do not match any user in this service');
  });

  test('locked out user cannot login', async () => {
    await loginPage.login('locked_out_user', 'secret_sauce');
    await loginPage.expectError('Sorry, this user has been locked out');
  });
});

What changed?

  • The import at the top pulls in your LoginPage class from the pages folder.
  • test.beforeEach now creates a fresh LoginPage instance and navigates to the login screen before every test. This eliminates duplicated setup.
  • Each test body dropped from 3-4 lines of locator code to a single loginPage.login() call. The tests read like plain English now.
  • The second and third tests no longer need the { page } parameter because they interact exclusively through the page object.
  • Save tests/login.spec.ts.
  • Run your login tests to confirm they still pass by running this command:
npx playwright test tests/login.spec.ts

You should see all 3 tests passing across Chromium, Firefox, and WebKit (9 total test runs).

Tests failing after refactor?

Check the import path. It should be '../pages/login.page' with two dots (going up one directory from tests/ into the project root, then down into pages/). A single dot or missing dot is the most common cause.

Also confirm you saved both files: pages/login.page.ts and tests/login.spec.ts.

help me fix my refactored Playwright tests

Why this pattern matters for interviews

Page Object Model is the number one topic interviewers ask about in QA automation roles. They want to see that you can structure tests so locator changes affect one file instead of fifty.

When interviewers ask "How do you handle locator maintenance?" your answer is this: define every locator once in a page class, expose actions as methods, and let tests call those methods. One change propagates everywhere.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: pages/login.page.ts, pages/inventory.page.ts, and tests/login.spec.ts.

ⓧ I'd like to double check the full code

Here is the full code for each file you created or modified in this step.

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

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.getByPlaceholder('Username');
    this.passwordInput = page.getByPlaceholder('Password');
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.errorMessage = page.locator('[data-test="error"]');
  }

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

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

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}
import { type Locator, type Page, expect } from '@playwright/test';

export class InventoryPage {
  readonly page: Page;
  readonly inventoryItems: Locator;
  readonly sortDropdown: Locator;
  readonly cartBadge: Locator;
  readonly cartLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.inventoryItems = page.locator('[data-test="inventory-item"]');
    this.sortDropdown = page.locator('[data-test="product-sort-container"]');
    this.cartBadge = page.locator('[data-test="shopping-cart-badge"]');
    this.cartLink = page.locator('[data-test="shopping-cart-link"]');
  }

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

  async removeItemFromCart(itemName: string) {
    const item = this.inventoryItems.filter({ hasText: itemName });
    await item.getByRole('button', { name: 'Remove' }).click();
  }

  async getCartBadgeCount(): Promise<number> {
    const text = await this.cartBadge.textContent();
    return Number(text);
  }

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

  async sortBy(option: string) {
    await this.sortDropdown.selectOption(option);
  }

  async getProductNames(): Promise<string[]> {
    const names = this.page.locator('[data-test="inventory-item-name"]');
    return await names.allTextContents();
  }

  async getProductPrices(): Promise<number[]> {
    const prices = this.page.locator('[data-test="inventory-item-price"]');
    const texts = await prices.allTextContents();
    return texts.map(text => parseFloat(text.replace('$', '')));
  }
}
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

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

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

  test('successful login with standard user', async ({ page }) => {
    await loginPage.login('standard_user', 'secret_sauce');
    await expect(page).toHaveURL(/inventory/);
  });

  test('failed login with invalid credentials', async () => {
    await loginPage.login('invalid_user', 'wrong_password');
    await loginPage.expectError('Username and password do not match any user in this service');
  });

  test('locked out user cannot login', async () => {
    await loginPage.login('locked_out_user', 'secret_sauce');
    await loginPage.expectError('Sorry, this user has been locked out');
  });
});

Your page objects are ready. Next up, you will use the InventoryPage class to write tests that verify products load correctly and that the shopping cart updates when items are added.

Test Product Catalog and Cart

In the previous step, you built Page Object Model classes and refactored your login tests to use them. Now it's time to put those Page Objects to work by testing the product catalog and shopping cart.

SauceDemo's inventory page loads 6 products, each with an "Add to cart" button. Your tests will verify that products load correctly and that the cart badge updates as items are added.

In this step, get ready to:
  • Write inventory tests that verify products load and the cart badge updates.
  • Create a CartPage class and write tests that verify cart add/remove operations.
  • Run the full test suite across all 3 browsers and review the HTML report.
Write inventory tests

Every test in this file needs to be logged in first. Instead of repeating login code in each test, you'll use test.beforeEach to run login setup automatically before each test.

  • In VS Code, open tests/inventory.spec.ts and replace its contents with this code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';

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

  // Runs before EACH test: logs in so tests start on the inventory page
  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    inventoryPage = new InventoryPage(page);
    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');
  });

  // Verify the page loads all 6 products
  test('products are displayed on inventory page', async () => {
    await expect(inventoryPage.inventoryItems).toHaveCount(6);
  });

  // Verify adding one item shows badge count of 1
  test('adding item to cart updates badge count', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    expect(await inventoryPage.getCartBadgeCount()).toBe(1);
  });

  // Verify adding two items shows badge count of 2
  test('adding multiple items updates badge count', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.addItemToCart('Sauce Labs Bike Light');
    expect(await inventoryPage.getCartBadgeCount()).toBe(2);
  });
});

What does this code do?

test.beforeEach runs before every single test in the describe block. It logs in with standard_user so each test starts on the inventory page without repeating login code. This is the equivalent of @BeforeEach in JUnit or @BeforeMethod in TestNG.

The three tests cover: product count on the page (expecting 6), single-item cart badge, and multi-item cart badge. Each test uses methods from your InventoryPage class instead of raw locators.

  • Save tests/inventory.spec.ts.
  • Run the inventory tests in headed mode by running this command:
npx playwright test tests/inventory.spec.ts --headed --project=chromium

You should see a browser window open, log in, and interact with the products. All 3 tests should pass.

Tests failing with timeout?

Make sure your beforeEach calls loginPage.goto() before loginPage.login(). Without navigating to the page first, the login locators can't be found.

Also check that your imports match the file paths exactly: '../pages/login.page' and '../pages/inventory.page'.

Help me debug my inventory tests

Create the CartPage and write cart tests

The cart page is a separate page in SauceDemo's flow. You need a dedicated Page Object to interact with cart-specific elements like the item list, remove buttons, and checkout button.

  • Create a new file pages/cart.page.ts and add this code:
import { type Locator, type Page, expect } from '@playwright/test';

export class CartPage {
  readonly page: Page;
  readonly cartItems: Locator;
  readonly checkoutButton: Locator;
  readonly continueShoppingButton: Locator;

  constructor(page: Page) {
    this.page = page;
    // Locators for cart page elements
    this.cartItems = page.locator('[data-test="inventory-item"]');
    this.checkoutButton = page.locator('[data-test="checkout"]');
    this.continueShoppingButton = page.locator('[data-test="continue-shopping"]');
  }

  // Extract all item names currently in the cart
  async getCartItemNames(): Promise<string[]> {
    const names = this.page.locator('[data-test="inventory-item-name"]');
    return await names.allTextContents();
  }

  // Remove a specific item by filtering on its name
  async removeItem(itemName: string) {
    const item = this.cartItems.filter({ hasText: itemName });
    await item.getByRole('button', { name: 'Remove' }).click();
  }

  // Click 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 pages. It defines locators for cart items, the checkout button, and the continue-shopping button.

getCartItemNames() uses allTextContents() to extract every item name as an array. This lets you assert that a specific product made it into the cart.

removeItem() uses the .filter({ hasText: itemName }) pattern to narrow down to a specific cart item, then clicks its Remove button.

  • Save pages/cart.page.ts.

You won't see a change yet. The next chunk uses this class in your cart tests.

  • Open tests/cart.spec.ts and replace its contents with this code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';
import { CartPage } from '../pages/cart.page';

test.describe('Cart Tests', () => {
  let loginPage: LoginPage;
  let inventoryPage: InventoryPage;
  let cartPage: CartPage;

  // Log in and set up all three page objects before each test
  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    inventoryPage = new InventoryPage(page);
    cartPage = new CartPage(page);
    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');
  });

  // Verify an added item shows up when navigating to the cart
  test('added item appears in cart', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    const items = await cartPage.getCartItemNames();
    expect(items).toContain('Sauce Labs Backpack');
  });

  // Verify removing an item leaves the cart empty
  test('removing item from cart works', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    await cartPage.removeItem('Sauce Labs Backpack');
    await expect(cartPage.cartItems).toHaveCount(0);
  });
});

What does this code do?

This test file uses three Page Objects together: LoginPage for authentication, InventoryPage for adding items, and CartPage for verifying the cart contents.

The first test adds an item from inventory, navigates to the cart, then checks the item name appears in the array returned by getCartItemNames(). The second test adds an item, navigates to cart, removes it, then asserts zero items remain.

Notice how readable the tests are with Page Objects. Each line reads like a description of what the user does, not a wall of locator code.

Why do cart tests need InventoryPage?

To test the cart, you first need items in it. SauceDemo doesn't let you add items from the cart page itself. You must add items from the inventory page, then navigate to the cart to verify they arrived. This is why the beforeEach sets up both InventoryPage and CartPage instances.

  • Save tests/cart.spec.ts.
  • Run the cart tests in headed mode by running this command:
npx playwright test tests/cart.spec.ts --headed --project=chromium

You should see the browser log in, add a backpack to the cart, navigate to the cart page, and verify the item is there. Both tests should pass.

Cart tests failing?

Check that your import path says '../pages/cart.page' (not '../pages/cart' or './pages/cart.page'). The relative path must go up one directory from tests/ to reach pages/.

If you see a timeout on the "Remove" button, verify that removeItem() in cart.page.ts uses { name: 'Remove' } (capital R).

Help me debug my cart tests

Run the full suite and review the HTML report

You now have 8 tests across 3 files: login, inventory, and cart. Time to run everything across all browsers and see your progress in the HTML report.

  • Run the full test suite across all 3 browsers by running this command:
npx playwright test
  • Open the HTML report by running:
npx playwright show-report

Your report should show 8 tests passing across Chromium, Firefox, and WebKit (24 total test runs). You can filter by browser to see results per engine.

What am I looking at in the report?

The HTML report groups tests by file and shows pass/fail status for each browser. Green checkmarks mean the test passed. Red X marks mean failure.

Click on any test name to see its execution details, including timing and any attached screenshots. This report is something you can screenshot for your portfolio.

Some browsers failing but not others?

This is rare with SauceDemo, but if WebKit tests fail while Chromium passes, it could be a timing issue. Check that all your assertions use await expect() (auto-retrying) rather than bare expect() without await.

Help me understand why my tests pass in one browser but fail in another

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here's what each file should look like at this point.

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

export class CartPage {
  readonly page: Page;
  readonly cartItems: Locator;
  readonly checkoutButton: Locator;
  readonly continueShoppingButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.cartItems = page.locator('[data-test="inventory-item"]');
    this.checkoutButton = page.locator('[data-test="checkout"]');
    this.continueShoppingButton = page.locator('[data-test="continue-shopping"]');
  }

  async getCartItemNames(): Promise<string[]> {
    const names = this.page.locator('[data-test="inventory-item-name"]');
    return await names.allTextContents();
  }

  async removeItem(itemName: string) {
    const item = this.cartItems.filter({ hasText: itemName });
    await item.getByRole('button', { name: 'Remove' }).click();
  }

  async proceedToCheckout() {
    await this.checkoutButton.click();
  }
}
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';

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

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

  test('products are displayed on inventory page', async () => {
    await expect(inventoryPage.inventoryItems).toHaveCount(6);
  });

  test('adding item to cart updates badge count', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    expect(await inventoryPage.getCartBadgeCount()).toBe(1);
  });

  test('adding multiple items updates badge count', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.addItemToCart('Sauce Labs Bike Light');
    expect(await inventoryPage.getCartBadgeCount()).toBe(2);
  });
});
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';
import { CartPage } from '../pages/cart.page';

test.describe('Cart Tests', () => {
  let loginPage: LoginPage;
  let inventoryPage: InventoryPage;
  let cartPage: CartPage;

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

  test('added item appears in cart', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    const items = await cartPage.getCartItemNames();
    expect(items).toContain('Sauce Labs Backpack');
  });

  test('removing item from cart works', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    await cartPage.removeItem('Sauce Labs Backpack');
    await expect(cartPage.cartItems).toHaveCount(0);
  });
});

Your test suite is growing. Next up, you'll tackle the most complex user journey: the full checkout flow from cart to order confirmation.

Test the Complete Checkout Flow

Your test suite now covers login, product browsing, and cart operations. The final piece is the most important user journey of all: the complete checkout flow.

In this step, you'll build a CheckoutPage class and write an end-to-end test that exercises the full path from adding an item to seeing the order confirmation. You'll also explore the trace viewer to debug test execution step by step.

In this step, get ready to:
  • Create a CheckoutPage class with locators and action methods.
  • Write end-to-end checkout tests covering happy path and validation failure.
  • Run the full suite and explore the trace viewer for debugging.
Create the CheckoutPage class

The checkout flow spans multiple pages: an information form, an order overview, and a completion screen. Your Page Object needs to cover all three stages with locators and methods that keep tests readable.

  • Create a new file called checkout.page.ts inside the pages/ folder by running this command:
touch pages/checkout.page.ts
  • Confirm the file exists by running:
ls pages/

You should see checkout.page.ts alongside login.page.ts, inventory.page.ts, and cart.page.ts.

  • Open pages/checkout.page.ts in VS Code and add the following code:
import { type Locator, type Page, expect } from '@playwright/test';

export class CheckoutPage {
  readonly page: Page;
  readonly firstNameInput: Locator;
  readonly lastNameInput: Locator;
  readonly postalCodeInput: Locator;
  readonly continueButton: Locator;
  readonly finishButton: Locator;
  readonly completeHeader: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    // Checkout step one: customer info form
    this.firstNameInput = page.getByPlaceholder('First Name');
    this.lastNameInput = page.getByPlaceholder('Last Name');
    this.postalCodeInput = page.getByPlaceholder('Zip/Postal Code');
    this.continueButton = page.locator('[data-test="continue"]');
    // Checkout step two: order overview and finish
    this.finishButton = page.locator('[data-test="finish"]');
    // Checkout complete page
    this.completeHeader = page.locator('[data-test="complete-header"]');
    // Error message for validation failures
    this.errorMessage = page.locator('[data-test="error"]');
  }

  // Fill out the checkout information form
  async fillInfo(firstName: string, lastName: string, postalCode: string) {
    await this.firstNameInput.fill(firstName);
    await this.lastNameInput.fill(lastName);
    await this.postalCodeInput.fill(postalCode);
  }

  // Click continue to move to order overview
  async continue() {
    await this.continueButton.click();
  }

  // Click finish to complete the order
  async finish() {
    await this.finishButton.click();
  }

  // Assert the order confirmation message appears
  async expectOrderComplete() {
    await expect(this.completeHeader).toHaveText('Thank you for your order!');
  }
}

What does this code do?

This class covers the entire checkout journey across three pages:

  • Step one locators (firstNameInput, lastNameInput, postalCodeInput) target the customer info form using getByPlaceholder() for user-facing locators.
  • Step two locators (finishButton) target the order overview page where the user confirms the purchase.
  • The expectOrderComplete() method uses toHaveText() to assert the confirmation message appears after a successful order.
  • Save pages/checkout.page.ts.

Seeing a red underline in VS Code?

If you see red squiggly lines under the import statement, make sure you spelled the filename exactly as checkout.page.ts and that it lives inside the pages/ folder. TypeScript resolves imports based on file paths.

Why is my CheckoutPage import showing an error?

Write the checkout tests

An end-to-end checkout test exercises the full user journey: login, add product, go to cart, fill checkout info, and confirm the order. This is the most valuable test type because it proves the entire system works together.

  • Open tests/checkout.spec.ts and replace the empty file with the following code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';
import { CartPage } from '../pages/cart.page';
import { CheckoutPage } from '../pages/checkout.page';

test.describe('Checkout Tests', () => {
  let loginPage: LoginPage;
  let inventoryPage: InventoryPage;
  let cartPage: CartPage;
  let checkoutPage: CheckoutPage;

  test.beforeEach(async ({ page }) => {
    // Initialize all page objects needed for checkout flow
    loginPage = new LoginPage(page);
    inventoryPage = new InventoryPage(page);
    cartPage = new CartPage(page);
    checkoutPage = new CheckoutPage(page);
    // Login before each test
    await loginPage.goto();
    await loginPage.login('standard_user', 'secret_sauce');
  });

  test('complete checkout flow end to end', async () => {
    // Add item and navigate to cart
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    // Start checkout process
    await cartPage.proceedToCheckout();
    // Fill in customer information
    await checkoutPage.fillInfo('John', 'Doe', '12345');
    await checkoutPage.continue();
    // Finish the order
    await checkoutPage.finish();
    // Verify order confirmation
    await checkoutPage.expectOrderComplete();
  });

  test('checkout fails without filling info', async () => {
    // Add item and go to checkout
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    await cartPage.proceedToCheckout();
    // Try to continue without filling the form
    await checkoutPage.continue();
    // Error message should appear
    await expect(checkoutPage.errorMessage).toBeVisible();
  });
});

What does this code do?

The first test walks through the full happy path: adding an item, completing checkout with valid info, and asserting the "Thank you for your order!" message appears. This is the most critical E2E test because it exercises every step a real customer takes.

The second test is a negative case: it attempts to continue checkout without filling in any info. The toBeVisible() assertion auto-retries until the error message element appears on screen.

  • Save tests/checkout.spec.ts.
  • Run the checkout tests in headed mode to watch them execute:
npx playwright test tests/checkout.spec.ts --headed

You should see browser windows open and the checkout flow run visually. Both tests should pass.

Tests failing with a timeout error?

Make sure you saved both pages/checkout.page.ts and tests/checkout.spec.ts. If the error mentions a locator not being found, double-check the placeholder text matches exactly: First Name, Last Name, Zip/Postal Code.

Help me debug my failing checkout tests

Run the full suite and explore the trace viewer

Now that all your test files are complete, it's time to run the entire suite across all browsers and generate trace files for debugging.

Why use --trace on locally?

Your config sets trace: 'on-first-retry', which only generates traces when a test fails and retries. Since local retries are set to 0, no traces are generated during normal runs. The --trace on flag forces trace recording on every test, letting you explore the trace viewer without needing a failure.

  • Run the full test suite with tracing enabled by running this command:
npx playwright test --trace on

All 10 tests (across 3 browsers) should pass. Trace files are saved in the test-results directory.

  • Open the trace viewer on one of the generated trace files by running:
npx playwright show-trace test-results/checkout-Checkout-Tests-complete-checkout-flow-end-to-end-chromium/trace.zip

What is the trace viewer?

The trace viewer is a GUI tool that shows a timeline of every action your test performed. You can click through each step and see a DOM snapshot of the page at that moment.

It also shows screenshots, network requests, console logs, and the exact locator used at each action. This is invaluable for debugging failures on CI where you can't watch the browser live.

  • In the trace viewer window, click through the actions to explore the checkout flow step by step.
  • Try clicking the Before and After tabs on any action to see the page state before and after it executed.

Trace file paths vary by test name

If the exact path above doesn't work, check what folders exist inside test-results/ by running ls test-results/. Each folder is named after the test and browser combination. Pick any trace.zip file from the results.

Don't see a test-results folder?

Make sure you ran the command with --trace on. Without this flag, traces are only generated on retries (and retries are set to 0 locally). Re-run npx playwright test --trace on and the folder will appear.

Why isn't my trace file being generated?

✔️ Awesome, I've got everything!

Great. Double check you've saved both files and your tests all pass.

ⓧ I'd like to double check the full code

Here is the complete code for both files:

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

export class CheckoutPage {
  readonly page: Page;
  readonly firstNameInput: Locator;
  readonly lastNameInput: Locator;
  readonly postalCodeInput: Locator;
  readonly continueButton: Locator;
  readonly finishButton: Locator;
  readonly completeHeader: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.firstNameInput = page.getByPlaceholder('First Name');
    this.lastNameInput = page.getByPlaceholder('Last Name');
    this.postalCodeInput = page.getByPlaceholder('Zip/Postal Code');
    this.continueButton = page.locator('[data-test="continue"]');
    this.finishButton = page.locator('[data-test="finish"]');
    this.completeHeader = page.locator('[data-test="complete-header"]');
    this.errorMessage = page.locator('[data-test="error"]');
  }

  async fillInfo(firstName: string, lastName: string, postalCode: string) {
    await this.firstNameInput.fill(firstName);
    await this.lastNameInput.fill(lastName);
    await this.postalCodeInput.fill(postalCode);
  }

  async continue() {
    await this.continueButton.click();
  }

  async finish() {
    await this.finishButton.click();
  }

  async expectOrderComplete() {
    await expect(this.completeHeader).toHaveText('Thank you for your order!');
  }
}
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { InventoryPage } from '../pages/inventory.page';
import { CartPage } from '../pages/cart.page';
import { CheckoutPage } from '../pages/checkout.page';

test.describe('Checkout Tests', () => {
  let loginPage: LoginPage;
  let inventoryPage: InventoryPage;
  let cartPage: CartPage;
  let checkoutPage: CheckoutPage;

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

  test('complete checkout flow end to end', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    await cartPage.proceedToCheckout();
    await checkoutPage.fillInfo('John', 'Doe', '12345');
    await checkoutPage.continue();
    await checkoutPage.finish();
    await checkoutPage.expectOrderComplete();
  });

  test('checkout fails without filling info', async () => {
    await inventoryPage.addItemToCart('Sauce Labs Backpack');
    await inventoryPage.goToCart();
    await cartPage.proceedToCheckout();
    await checkoutPage.continue();
    await expect(checkoutPage.errorMessage).toBeVisible();
  });
});

Your test suite now covers the complete user journey from login to order confirmation. Next, you'll run everything across all three browsers and explore the full reporting capabilities.

Run Cross-Browser Tests and Review Reports

You now have a complete test suite with 10 tests covering login, inventory, cart, and checkout flows. Every test is backed by a Page Object Model that keeps your code clean and maintainable.

The real power of Playwright comes from running those same tests across multiple browser engines simultaneously. In this step, you'll execute your full suite across Chromium, Firefox, and WebKit, then explore the reporting and debugging tools that make Playwright a professional-grade solution.

In this step, get ready to:
  • Run all 10 tests across 3 browser engines (30 total test runs).
  • Review the HTML report and filter results by browser.
  • Explore UI Mode and run a single test file in headed mode.
Run the full suite across all three browsers

Your playwright.config.ts defines three browser projects: Chromium, Firefox, and WebKit. When you run the test command without specifying a project, Playwright executes every test against all three engines.

  • Run your full test suite across all browsers by running this command in the VS Code terminal:
npx playwright test

You should see output showing 30 tests passing (10 tests multiplied by 3 browser projects). Each test name appears with its browser engine label.

Why cross-browser testing matters

Playwright tests against three browser engines that power the vast majority of web traffic:

  • Chromium powers Chrome, Edge, Brave, and Opera.
  • Firefox uses Mozilla's Gecko engine.
  • WebKit powers Safari on macOS and iOS.

Each engine interprets CSS, JavaScript, and the DOM slightly differently. A feature that works in Chrome might break in Safari. By running your tests across all three, you catch these issues automatically in seconds.

Tests failing on one browser?

If tests pass on Chromium but fail on Firefox or WebKit, check the error message in the terminal output. The most common cause is a timing issue where one browser engine loads content slightly slower.

Playwright's auto-retry handles most timing differences, but if you see a timeout error, the site might have been temporarily slow. Run the suite again to confirm.

Help me debug why my Playwright tests pass on Chromium but fail on another browser

  • Open the HTML report to see a visual dashboard of your results by running:
npx playwright show-report

Your default browser opens with an interactive report. You should see all 30 test runs listed with green checkmarks.

  • Click on any browser name in the report sidebar to filter results by engine.
  • Click on an individual test name to see its execution timeline and any attached traces.

The HTML report as a portfolio piece

This HTML report is something you can screenshot for your LinkedIn or include in interview presentations. It shows total tests run per browser, pass/fail status, test duration, and error messages with screenshots for any failures.

Hiring managers who see this immediately know you can build professional test infrastructure.

Explore UI Mode for time-travel debugging

The HTML report shows results after tests finish. UI Mode lets you watch tests run in real time and step through them action by action. It is Playwright's most powerful debugging tool.

  • Launch UI Mode by running this command:
npx playwright test --ui

A new window opens with your test files listed on the left. You should see all four test files with their individual test cases nested inside.

  • Click the green play button next to any test to run it individually.
  • After the test completes, click on any action step in the timeline panel on the right.
  • Notice how the browser preview updates to show exactly what the page looked like at that moment.

What is time-travel debugging?

UI Mode records a screenshot and DOM snapshot at every action in your test. You can click on any step in the timeline and the preview jumps to exactly what the browser showed at that moment.

This eliminates the "works on my machine" problem. When a test fails, you can see exactly what happened at each step without re-running anything.

  • Close the UI Mode window when you are done exploring.

UI Mode window not opening?

Make sure you closed any previous Playwright processes in the terminal. Press Ctrl+C to stop a running process, then try the command again.

Help me fix Playwright UI Mode not opening

Run a single test file in headed mode

Sometimes you want to watch a specific test flow execute in a real browser window. Headed mode opens a visible browser so you can see every click, navigation, and form fill happen live.

  • Run the checkout tests in a visible Chromium window by running this command:
npx playwright test tests/checkout.spec.ts --project=chromium --headed

A Chromium browser window opens. You should see it navigate to SauceDemo, log in, add an item to the cart, proceed through checkout, and complete the order. It happens fast, but you can follow along.

What do these flags do?

  • tests/checkout.spec.ts tells Playwright to run only that specific test file instead of the entire suite.
  • --project=chromium runs against a single browser engine instead of all three.
  • --headed opens a visible browser window instead of running headless (invisible).

Combining these flags is useful when you want to quickly debug one specific flow without waiting for the full suite to finish.

Browser window flashes and disappears?

That is expected. Playwright runs tests at machine speed, so the browser opens, executes the flow, and closes in just a few seconds. If you want to slow things down, UI Mode (from the previous substep) is a better option for watching tests step by step.

Help me slow down Playwright headed mode so I can follow along

Your test suite is now proven across all three browser engines. You have the HTML report, UI Mode, and headed execution in your toolkit. Next up, you'll tackle a Secret Mission that adds sorting validation tests to demonstrate data-driven testing skills.

Secret mission

Add Product Sort Validation Tests

Your test suite covers login, products, cart, and checkout. But what about the sort dropdown? Real QA engineers validate that data-ordering features actually work. Build a sort validation suite that proves all four sort options produce correctly ordered results across every browser.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing cloud costs or subscriptions to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The playwright-project folder on your Desktop (test files, page objects, config, node_modules).
  • Playwright browser binaries (Chromium, Firefox, WebKit) stored in your system cache.
  • VS Code with the Playwright Test extension.

Keep everything running

No action needed. Choose this if you plan to keep building on your test suite or want to reference your Page Object Model code later.

  • Your playwright-project folder stays on your Desktop, ready to use anytime.
  • Playwright browser binaries remain cached for fast test runs.
  • Node.js remains installed on your system. You can use it for other JavaScript and TypeScript projects.
  • VS Code and the Playwright Test extension stay available for future projects.

Pause - I'll come back to this later

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

  • Remove Playwright's cached browser binaries by running this command from your project folder:
  • Your test files, page objects, and configuration remain untouched in the playwright-project folder.
  • When you come back, reinstall the browsers by running npx playwright install from your project folder.
npx playwright uninstall

Delete - I don't want to use this again

Remove all project files, browser binaries, and the VS Code extension to start completely fresh.

  • Remove the Playwright browser binaries by running:
npx playwright uninstall
  • Delete the project folder by running:
cd ~/Desktop
rm -rf playwright-project
  • Uninstall the Playwright Test extension in VS Code by pressing Cmd+Shift+X to open the Extensions panel, finding Playwright Test for VSCode, and clicking Uninstall.
  • To remove VS Code itself, open Finder, go to Applications, and drag Visual Studio Code to the Trash.

Nice Work!

Nice Work!

Nice work! You just built a professional-grade Playwright test suite from scratch, complete with cross-browser coverage and a clean Page Object Model architecture.

You've learned how to:

  • Built a Page Object Model architecture with 4 page classes and 14 automated E2E tests covering login, product catalog, cart, and checkout flows.
  • Ran cross-browser tests across Chromium, Firefox, and WebKit with HTML reporting and trace recording for debugging failed tests.
  • Wrote professional TypeScript test code using async/await, user-facing locator strategies, and auto-retrying web-first assertions.
  • Built a comprehensive sort validation suite that extracts page data and validates business logic programmatically across all 3 browsers.

Ready to quiz yourself?