You have Playwright installed, scaffolded, and a sanity assertion passing across three browsers. This lesson is the first one with real, runnable code against a real app — three tests for an e-commerce product page, a tour of how test.describe, test, and test.beforeEach glue them together, and the three ways to run the suite. Every snippet is meant to be typed (or pasted) into your scaffolded project and run.
Anatomy of a Playwright test
Open tests/home.spec.ts and write:
import { test, expect } from "@playwright/test";
test("should display the welcome message", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toContainText("Welcome");
});Save. The Playwright runner — whether you use UI Mode or the CLI — picks up the new test immediately. Read the file from the outside in:
import { test, expect } from "@playwright/test"— pulls in the test runner and the assertion library. Every Playwright spec starts with this exact line. (Only ever import from@playwright/test, never fromplaywright.)test("...", async ({ page }) => { ... })— a single test case. The string is the human-readable description; the function is what runs.async ({ page })— a fixture. Playwright destructurespageout of a fixture object and gives the test a fresh, fully-isolated browser tab. Each test gets its ownpage, so one test's cookies and DOM state never leak into the next.await page.goto("/")— navigate. BecausebaseURLis set inplaywright.config.ts,"/"resolves to whatever you configured. Note theawait— every Playwright command is async and returns a Promise.await expect(...).toContainText("Welcome")— a web-first assertion.expectretries automatically until the heading contains "Welcome" or the assertion timeout (5 seconds by default) fires. No manual wait needed.
Three lines, one assertion, one passing test across three browsers. That's the whole shape.
Running tests three ways
Headless — for CI and bulk execution:
npx playwright testPlaywright runs every spec under tests/ in headless Chromium, Firefox, and WebKit, prints a per-spec summary table to stdout, and exits with code 0 (pass) or non-zero (fail). On every failure it captures a screenshot in test-results/; on every retry it captures a trace. CI pipelines use this exact command.
UI Mode — for authoring and debugging (this is your day-to-day):
npx playwright test --uiA desktop window opens with the test list on the left, a browser preview in the middle, and a timeline of every action at the bottom. Click a test, watch it run, scrub backward through the action timeline, hover any step to see the DOM at that moment. Save the file in VS Code and the runner re-executes only the affected tests. Far more powerful than Cypress's runner — and the default for serious authoring.
Headed — for watching a CI-style run with a visible browser, useful for one-off debugging:
npx playwright test --headedTo run a specific spec or one project:
npx playwright test tests/home.spec.ts
npx playwright test --project=chromium
npx playwright test --grep "welcome" # run only tests matching a nameIf you added the npm scripts from lesson 2, npm test, npm run test:ui, and npm run test:headed are the shortcuts you'll actually type.
A real multi-test spec
A single test rarely tells you anything useful. Real specs cluster three or four related tests under one test.describe block and share their setup with test.beforeEach:
import { test, expect } from "@playwright/test";
test.describe("Product search", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/products");
});
test("displays products on the page", async ({ page }) => {
const products = page.getByTestId("product-card");
await expect(products).toHaveCount(10);
});
test("filters products by category", async ({ page }) => {
await page.getByLabel("Category").selectOption("Electronics");
const products = page.getByTestId("product-card");
await expect(products.first()).toContainText("Electronics");
});
test("searches for a product by name", async ({ page }) => {
await page.getByPlaceholder("Search products").fill("Laptop");
await page.getByRole("button", { name: "Search" }).click();
await expect(page.getByTestId("product-card")).toHaveCount(3);
});
});Save it as tests/products.spec.ts. Three tests, each independent, each starting from a fresh navigation to /products. Walk through what's new:
test.describe("Product search", () => { ... })is a test suite — a labelled group of related tests. Same role asdescribein Cypress or Jest. The string shows up in the runner UI and the CI output.test.beforeEach(async ({ page }) => ...)runs before everytest. Use it for shared setup so the tests stay focused on what they're actually verifying. Thepagefixture is fresh in the hook the same way it is in the test.page.getByTestId("product-card")returns aLocator— a lazy, queryable handle to one or more elements. Locators don't fetch the DOM until youawaitan action or assertion on them. That laziness is what letsexpect(products).toHaveCount(10)retry until the count matches.page.getByLabel("Category").selectOption("Electronics")finds a native<select>by its label and chooses the option by visible text. Cypress's equivalent iscy.get('select').select('Electronics')— same shape, different selector strategy.page.getByPlaceholder("Search products").fill("Laptop")finds the input by its placeholder and types..fill()clears and types in one shot; you'll meet.type()(character-by-character, for autocomplete) in the next chapter.page.getByRole("button", { name: "Search" }).click()finds the Search button by its accessibility role and name. This is the Playwright-recommended locator pattern — chapter 2 covers why.
Run this spec against any app with [data-testid] attributes on its product page. If your app doesn't have them yet, swap in the locators that match what's there — but stay disciplined: chapter 2 is going to argue hard for data-testid and accessibility-based locators.
Test-execution flow
The two things to internalise: beforeEach runs before every test, not just before the first one. And every test starts from a clean browser context — Playwright destroys the context after each test and creates a new one, so cookies, localStorage, and any open tabs from the previous test are gone.
What if there's no real app to test against?
The spec above assumes a running e-commerce site at baseURL. If you don't have one yet, three options:
- Sauce Demo — set
baseURL: "https://www.saucedemo.com"(standard_user/secret_sauce). It's the canonical public sandbox for automation practice and we'll use it across most lessons. - The Playwright demo TodoMVC at
https://demo.playwright.dev/todomvc— the team's own reference app, used in the official docs. - Your own app running locally on
http://localhost:3000— the realistic scenario. This is what every real QA job looks like.
For the rest of this course, we'll assume an e-commerce target with [data-testid] attributes wherever it matters. If you're following along against a different app, the patterns transfer; just substitute selectors.
Hooks beyond beforeEach
test.beforeEach is the workhorse, but the full set is:
test.beforeAll(async () => { ... })— runs once before any test in the file. Nopagefixture by default (worker-scoped). Good for one-time expensive setup like database seeding.test.beforeEach(async ({ page }) => { ... })— runs before every test. Good for navigation and per-test setup.test.afterEach(async ({ page }) => { ... })— runs after every test, even on failure. Good for cleanup like deleting test users.test.afterAll(async () => { ... })— runs once at the end. Rare; mostly for teardown ofbeforeAllsetup.
Don't use beforeAll to set up state that tests will mutate — Playwright contexts get reset per test by default, so anything you goto in beforeAll won't survive into the test. beforeEach is the safe default.
Coming from Cypress?
The shape is nearly identical, with three notable differences:
- Cypress:
describe(...),it(...),beforeEach(...)as globals (no import). Playwright: importtestand calltest.describe,test,test.beforeEach. - Cypress: chained commands, no async/await (
cy.get(...).click()). Playwright: async/await every command (await page.locator(...).click()). - Cypress:
cy.visit('/products'). Playwright:await page.goto('/products'). Same idea, different name.
If your muscle memory is "every command should chain off cy", flip it to "every command should await page (or a Locator off the page)". After two or three real specs the pattern locks in.
⚠️ Common mistakes
- Forgetting
awaiton a Playwright command.page.goto('/')withoutawaitreturns a Promise that floats — the test moves on, the next line runs against the wrong page, the assertion fails mysteriously. Every Playwright action and every assertion needsawait. Configure ESLint with@typescript-eslint/no-floating-promisesand the linter catches this for you. - Putting setup in the test body instead of
beforeEach. Three tests, three copy-pastedawait page.goto('/products')lines. Now the URL changes and you have to update three places — or worse, miss one.test.beforeEachexists to centralise navigation. Use it from day one. - Reusing
pageacross tests viabeforeAll. Each test gets its ownpagefixture by design — that isolation is why Playwright tests are stable. If you find yourself writingbeforeAll(async () => { page = await browser.newPage() }), you're working against the framework. UsebeforeEach, or for stateful flows, see chapter 5's worker-scoped fixtures.
🎯 Practice task
Author and run a multi-test spec end to end. 25-30 minutes.
-
In your scaffolded project, set
baseURL: "https://www.saucedemo.com"inplaywright.config.ts. (Sauce Demo is the public sandbox for automation practice — credentialsstandard_user/secret_sauce.) -
Create
tests/login.spec.tswith atest.describeblock named "Login" and three tests:import { test, expect } from "@playwright/test"; test.describe("Login", () => { test("loads the login page", async ({ page }) => { await page.goto("/"); await expect(page).toHaveURL(/saucedemo/); }); test("logs in with valid credentials", async ({ page }) => { await page.goto("/"); 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("shows an error for invalid credentials", async ({ page }) => { await page.goto("/"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("wrong"); await page.getByRole("button", { name: "Login" }).click(); await expect(page.locator("[data-test='error']")).toContainText("do not match"); }); }); -
Move the
await page.goto("/")into atest.beforeEach. Confirm all three tests still pass against all three browsers. -
Run only this spec from the CLI:
npm test -- tests/login.spec.ts. Note that Playwright runs it three times (once per browser) — that's the multi-browser default in action. Open the report withnpm run reportand inspect the three sets of results side by side. -
Force a failure to see Playwright's debugging in action. Change the error-message expectation to
"Welcome, Admin"(a string that won't appear). Re-run. The HTML report now shows the failure with a screenshot, the action that failed, and (becausetrace: 'on-first-retry'is set) a trace.zip on the retry. Open it withnpx playwright show-trace test-results/...zipand scrub through the timeline. -
Stretch: add a fourth test that logs in, clicks "Add to cart" on one product, and asserts the cart badge shows "1". You'll use
await page.locator(".inventory_item").first().getByRole("button", { name: "Add to cart" }).click()andawait expect(page.locator(".shopping_cart_badge")).toHaveText("1"). This is your first complete user-flow test — the kind every product team has dozens of.
Once this works headlessly through npm test and interactively through npm run test:ui, you've completed the loop a real Playwright engineer runs daily. The next lesson opens up codegen and the inspector — Playwright's two recording-and-debugging tools that turn "I don't know which selector to use" into a five-second answer.