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

Playwright Tutorial for Beginners: Write, Run, and Debug Your First Tests

Create a Playwright project, write login and checkout tests for a real demo shop, see what a failing assertion looks like, and use fixtures and the trace viewer. Copy the code and it runs.

QA Vibes EditorialPublished Updated 8 minTested with Playwright 1.63.0, Node.js 20.20.2, Chromium 153Revision history ↓

Key takeaways

  • One command creates a working project: npm init playwright@latest.
  • Find elements the way users see them (getByRole, getByPlaceholder, getByTestId) and await every action.
  • Let expect retry instead of adding fixed waits.
  • Break each new test once to confirm it can fail, and learn to read the failure output.
  • Move repeated setup, such as logging in, into a fixture.
Contents (13 sections)

Introduction

This tutorial takes you from an empty folder to three passing end-to-end tests. You won't test a made-up example.com app: every test runs against Sauce Demo, a public practice shop run by Sauce Labs, so you can copy the code and run it straight away.

What you will do:

  1. Create a Playwright project with one command.
  2. Point it at the demo shop.
  3. Write a login test and a checkout test.
  4. Break a test on purpose and read the failure.
  5. Move the login steps into a fixture.
  6. Debug with UI mode and the trace viewer.

We ran every command below on Windows 11 with Node.js 20.20.2 and Playwright 1.63.0. The outputs are copied from those runs.

What you need

  • Node.js, a current long-term support release. Check with node --version.
  • A terminal and an editor. VS Code with the official Playwright extension is a good start, but it isn't required.

No prior testing experience is needed. If you know what a function and await are, you can follow along.

Step 1: create the project

mkdir my-first-tests
cd my-first-tests
npm init playwright@latest -- --quiet --browser=chromium --lang=TypeScript

Without the flags, the installer asks a few questions: TypeScript or JavaScript, where to put tests, whether to add a GitHub Actions workflow, and which browsers to download. The flags answer them for you: TypeScript, Chromium only.

The installer ends with a summary. The important part:

✔ Success! Created a Playwright Test project at C:\...\my-first-tests
 
Inside that directory, you can run several commands:
 
  npx playwright test
    Runs the end-to-end tests.
 
  npx playwright test --ui
    Starts the interactive UI mode.

It created four files: package.json, .gitignore, playwright.config.ts, and tests/example.spec.ts. The example test opens playwright.dev. Delete it; you'll write your own.

Step 2: configure the base URL

The generated playwright.config.ts is mostly comments. These are the settings that matter, with two lines added under use:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: "html",
  use: {
    baseURL: "https://www.saucedemo.com",
    testIdAttribute: "data-test",
    trace: "on-first-retry",
  },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});

What each line does:

  • baseURL lets tests call page.goto("/") instead of repeating the full address. Switching to a staging server later is a one-line change.
  • testIdAttribute tells getByTestId() which attribute to read. Playwright looks for data-testid by default; Sauce Demo uses data-test.
  • retries and forbidOnly only change behavior when the CI environment variable is set. Locally a failing test fails once, and a forgotten test.only doesn't break anything.
  • trace: "on-first-retry" records a trace only when a test is retried, which keeps local runs fast.

The generated file also sets workers: process.env.CI ? 1 : undefined, which runs tests one at a time on CI. We left it out: this site's tests run in parallel on CI without problems, and you can add it back if your app can't handle parallel sessions.

Step 3: write your first test

Create tests/login.spec.ts:

import { test, expect } from "@playwright/test";
 
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
test("standard user sees the product list", async ({ page }) => {
  await page.goto("/");
  await page.getByPlaceholder("Username").fill("standard_user");
  await page.getByPlaceholder("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Login" }).click();
 
  await expect(page).toHaveURL(/inventory\.html$/);
  await expect(page.getByTestId("title")).toHaveText("Products");
});
 
test("locked out user gets a clear error", async ({ page }) => {
  await page.goto("/");
  await page.getByPlaceholder("Username").fill("locked_out_user");
  await page.getByPlaceholder("Password").fill(PASSWORD);
  await page.getByRole("button", { name: "Login" }).click();
 
  await expect(page.getByTestId("error")).toHaveText(
    "Epic sadface: Sorry, this user has been locked out.",
  );
  await expect(page).not.toHaveURL(/inventory/);
});

Four ideas are packed into these lines:

  1. { page } is a fixture. Playwright gives every test a fresh browser page, isolated from other tests: no shared cookies, no leftover login.
  2. Locators describe what a user sees. getByPlaceholder("Username") and getByRole("button", { name: "Login" }) keep working when a developer renames a CSS class. getByTestId is the fallback for elements with no good label.
  3. Every action is awaited. A missing await lets the next line run before the click finishes, which is the most common cause of flaky beginner tests.
  4. expect retries. toHaveText keeps checking for up to five seconds, so you never need waitForTimeout.

The demo password is public and printed on the login page. It still comes from an environment variable with a fallback, because that's the habit you want for real credentials, and it means the file never contains a secret worth leaking.

Step 4: run the tests

npx playwright test

Add a quick checkout test first (Step 6 shows it), or run just the login file with npx playwright test login. With all three tests from this tutorial, our run printed:

Running 3 tests using 3 workers
  ok 2 [chromium] › tests\login.spec.ts:15:5 › locked out user gets a clear error (1.1s)
  ok 1 [chromium] › tests\login.spec.ts:5:5 › standard user sees the product list (1.2s)
  ok 3 [chromium] › tests\checkout.spec.ts:3:5 › customer can buy a backpack (1.6s)
 
  3 passed (6.4s)

The tests ran at the same time in three workers, which is why they finish out of order. Browsers run headless, so no window opens. Add --headed to watch.

Open the HTML report with:

npx playwright show-report

Step 5: break a test and read the failure

A test you have never seen fail proves very little. In the checkout test from Step 6, we changed the expected total from $32.39 to $32.00 and ran it again:

Error: expect(locator).toHaveText(expected) failed
 
Locator:  getByTestId('total-label')
Expected: "Total: $32.00"
Received: "Total: $32.39"
Timeout:  5000ms
 
Call log:
  - Expect "toHaveText" getByTestId('total-label') with timeout 5000ms
  - waiting for getByTestId('total-label')
    14 × locator resolved to <div data-test="total-label" class="summary_total_label">Total: $32.39</div>
       - unexpected value "Total: $32.39"

Read it from the top:

  • Expected and Received tell you what was wrong.
  • 14 × locator resolved shows the retry at work: Playwright found the element 14 times in five seconds and the text never matched. The element existed, so this is a wrong value, not a missing element.
  • The code frame below the log (omitted here) points at the exact line.

Change the value back before moving on.

Step 6: log in once with a fixture

Both login tests repeat the same four lines, and every future test would too. A custom fixture runs the steps for any test that asks for it. Create tests/fixtures.ts:

import { test as base, expect, type Page } from "@playwright/test";
 
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
 
export const test = base.extend<{ shopper: Page }>({
  shopper: async ({ page }, use) => {
    await page.goto("/");
    await page.getByPlaceholder("Username").fill("standard_user");
    await page.getByPlaceholder("Password").fill(PASSWORD);
    await page.getByRole("button", { name: "Login" }).click();
    await expect(page).toHaveURL(/inventory\.html$/);
    await use(page);
  },
});
 
export { expect };

Everything before use(page) is setup; anything after it would be teardown. Now tests/checkout.spec.ts starts on the product list:

import { test, expect } from "./fixtures";
 
test("customer can buy a backpack", async ({ shopper: page }) => {
  await page
    .getByTestId("inventory-item")
    .filter({ hasText: "Sauce Labs Backpack" })
    .getByRole("button", { name: "Add to cart" })
    .click();
  await expect(page.getByTestId("shopping-cart-badge")).toHaveText("1");
 
  await page.getByTestId("shopping-cart-link").click();
  await page.getByRole("button", { name: "Checkout" }).click();
  await page.getByRole("textbox", { name: "First Name" }).fill("Ada");
  await page.getByRole("textbox", { name: "Last Name" }).fill("Lovelace");
  await page.getByRole("textbox", { name: "Zip/Postal Code" }).fill("10115");
  await page.getByRole("button", { name: "Continue" }).click();
 
  await expect(page.getByTestId("total-label")).toHaveText("Total: $32.39");
  await page.getByRole("button", { name: "Finish" }).click();
  await expect(page.getByRole("heading", { name: "Thank you for your order!" })).toBeVisible();
});

Look at the first statement. The page lists six products, each with an "Add to cart" button. filter({ hasText }) narrows the six product cards to the backpack before looking for the button, so the test adds exactly the item it names. getByRole("button", { name: "Add to cart" }).first() would also pass today, but it would silently start testing a different product if the sort order changed.

The expected total is not a guess: the backpack costs $29.99 and the shop adds $2.40 tax.

Step 7: debug with UI mode and traces

Two tools cover almost every debugging session:

npx playwright test --ui

UI mode lists your tests, runs them on click, and shows a timeline of every action with a DOM snapshot before and after it. Hover over an action to see which element the locator matched.

For a failure you can't reproduce interactively, record a trace:

npx playwright test --trace on
npx playwright show-report

Open the failed test in the report and click the trace. It contains the same timeline plus network requests, console messages, and the source line for each step. On CI, the trace: "on-first-retry" setting from Step 2 records one automatically whenever a test is retried.

Mistakes to avoid from day one

  • Fixed waits. await page.waitForTimeout(3000) makes every run three seconds slower and still fails when the app takes four. Assert on the state you need instead.
  • Selector shorthands. page.click("#login") and page.fill("#user", "…") work but are discouraged in Playwright's docs; use locators as above.
  • Assertions that can't fail. expect(page.getByText("Done")).toBeTruthy() passes even when the text is missing, because a locator object is always truthy. Use await expect(locator).toBeVisible().
  • Tests that depend on each other. Each test gets a fresh page, so a test that assumes another one logged in first will fail when they run in parallel.

Paste a test into our free Playwright test reviewer to check it for these and 16 other problems. Every Playwright example on this page passes it.

Where to go next

Conclusion

A useful first suite is small: a login test, a failure case, and one journey that earns money. Write locators the way a user would describe the page, await every action, let expect do the waiting, and break each new test once to see that it can fail. Everything else in Playwright builds on those habits.

Sources and further reading

Tools mentioned

PlaywrightUI AutomationOpen source
GitHub ActionsCI/CDFree plan
BrowserStackDevice CloudFree trial

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

Revision history

Rewritten as a hands-on tutorial against Sauce Demo, with every command and output taken from a real run on Playwright 1.63.0.
Revised during a site-wide content audit.
First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

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

Practise reading results

Why did this test pass?

Predict what a test does on a buggy build, then find out why, from real recorded runs.