Writing and Running Your First Test

8 min read

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 from playwright.)
  • test("...", async ({ page }) => { ... }) — a single test case. The string is the human-readable description; the function is what runs.
  • async ({ page }) — a fixture. Playwright destructures page out of a fixture object and gives the test a fresh, fully-isolated browser tab. Each test gets its own page, so one test's cookies and DOM state never leak into the next.
  • await page.goto("/") — navigate. Because baseURL is set in playwright.config.ts, "/" resolves to whatever you configured. Note the await — every Playwright command is async and returns a Promise.
  • await expect(...).toContainText("Welcome") — a web-first assertion. expect retries 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 test

Playwright 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 --ui

A 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 --headed

To 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 name

If 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 as describe in Cypress or Jest. The string shows up in the runner UI and the CI output.
  • test.beforeEach(async ({ page }) => ...) runs before every test. Use it for shared setup so the tests stay focused on what they're actually verifying. The page fixture is fresh in the hook the same way it is in the test.
  • page.getByTestId("product-card") returns a Locator — a lazy, queryable handle to one or more elements. Locators don't fetch the DOM until you await an action or assertion on them. That laziness is what lets expect(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 is cy.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. No page fixture 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 of beforeAll setup.

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: import test and call test.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 await on a Playwright command. page.goto('/') without await returns 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 needs await. Configure ESLint with @typescript-eslint/no-floating-promises and the linter catches this for you.
  • Putting setup in the test body instead of beforeEach. Three tests, three copy-pasted await page.goto('/products') lines. Now the URL changes and you have to update three places — or worse, miss one. test.beforeEach exists to centralise navigation. Use it from day one.
  • Reusing page across tests via beforeAll. Each test gets its own page fixture by design — that isolation is why Playwright tests are stable. If you find yourself writing beforeAll(async () => { page = await browser.newPage() }), you're working against the framework. Use beforeEach, 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.

  1. In your scaffolded project, set baseURL: "https://www.saucedemo.com" in playwright.config.ts. (Sauce Demo is the public sandbox for automation practice — credentials standard_user / secret_sauce.)

  2. Create tests/login.spec.ts with a test.describe block 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");
      });
    });
  3. Move the await page.goto("/") into a test.beforeEach. Confirm all three tests still pass against all three browsers.

  4. 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 with npm run report and inspect the three sets of results side by side.

  5. 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 (because trace: 'on-first-retry' is set) a trace.zip on the retry. Open it with npx playwright show-trace test-results/...zip and scrub through the timeline.

  6. 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() and await 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.

// tip to track lessons you complete and pick up where you left off across devices.