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:
- Create a Playwright project with one command.
- Point it at the demo shop.
- Write a login test and a checkout test.
- Break a test on purpose and read the failure.
- Move the login steps into a fixture.
- 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=TypeScriptWithout 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:
baseURLlets tests callpage.goto("/")instead of repeating the full address. Switching to a staging server later is a one-line change.testIdAttributetellsgetByTestId()which attribute to read. Playwright looks fordata-testidby default; Sauce Demo usesdata-test.retriesandforbidOnlyonly change behavior when theCIenvironment variable is set. Locally a failing test fails once, and a forgottentest.onlydoesn'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:
{ page }is a fixture. Playwright gives every test a fresh browser page, isolated from other tests: no shared cookies, no leftover login.- Locators describe what a user sees.
getByPlaceholder("Username")andgetByRole("button", { name: "Login" })keep working when a developer renames a CSS class.getByTestIdis the fallback for elements with no good label. - Every action is awaited. A missing
awaitlets the next line run before the click finishes, which is the most common cause of flaky beginner tests. expectretries.toHaveTextkeeps checking for up to five seconds, so you never needwaitForTimeout.
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 testAdd 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-reportStep 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 resolvedshows 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 --uiUI 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-reportOpen 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")andpage.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. Useawait 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
- Run these tests on every pull request: integrating tests into CI/CD.
- Learn what the demo shop's other users break on purpose: top mistakes in UI test automation.
- When a test fails only sometimes: flaky tests hide behind retries.
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.