How it works
The explanation, with examples you can run.
A journey, written like a user
import { expect, test } from "@playwright/test" test("a customer can buy a T-shirt", async ({ page }) => { await page.goto("/products/tshirt") await page.getByLabel("Size").selectOption("M") await page.getByRole("button", { name: "Add to cart" }).click() await page.getByRole("link", { name: "Cart" }).click() await expect(page.getByRole("listitem")).toHaveCount(1) await page.getByRole("button", { name: "Check out" }).click() await expect(page.getByRole("heading", { name: "Thank you for your order" })).toBeVisible()})There are no waits in it. Each click() first runs actionability checks: the element must be visible, stable (not animating), enabled, and the actual target at the click point, not hidden under an overlay. Each expect(locator) is a web-first assertion that keeps querying the page until it matches, 5 seconds by default (expect.timeout in the config changes that).
Locators that survive redesigns
Prefer getByRole, getByLabel, getByPlaceholder and getByText. They describe what a user perceives, and they double as a light accessibility check: if you can't find a button by its role and name, neither can a screen reader. Use getByTestId when there is no accessible name. Avoid long CSS or XPath chains such as div.cart > ul li:nth-child(2) button, which break whenever the markup changes. Narrow down with filter and chaining instead:
await page.getByRole("listitem").filter({ hasText: "T-shirt" }).getByRole("button", { name: "Remove" }).click()Isolation and speed
Logging in through the UI in every test is slow. A setup project logs in once and saves the cookies and storage to a file, and other projects start from that file:
// playwright.config.tsimport { defineConfig, devices } from "@playwright/test" export default defineConfig({ use: { baseURL: "http://localhost:3000", trace: "on-first-retry" }, webServer: { command: "npm run start", url: "http://localhost:3000", reuseExistingServer: !process.env.CI }, projects: [ { name: "setup", testMatch: /.*\.setup\.ts/ }, { name: "chromium", use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" }, dependencies: ["setup"], }, ],})Other habits:
- Create test data through the API in a
beforeEachor a fixture, not through the UI. - Don't test third-party sites. Mock their responses with
page.route(). - Turn on the
@typescript-eslint/no-floating-promiseslint rule. A missingawaitis a common cause of tests that pass without checking anything. npx playwright codegen <url>records a session and suggests locators, which is a quick way to start a test.
When it fails: the trace
With trace: "on-first-retry", a failing test that is retried records a trace: every action with its locator, DOM snapshots before and after, console output and network requests. Open it with npx playwright show-trace trace.zip, or drop it on trace.playwright.dev. It usually shows you the cause straight away: the button that was disabled, the request that returned 500, the dialog nobody expected.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.