Playwright end-to-end tests that hold up
Find elements the way users do, let assertions wait for the condition, start every test from a clean context, and read the trace.
Predict first
Before you read: predict
The candidate app's last screen shows the two buttons below. A new journey step is meant to submit the exam.
<button>Submit answer</button>
<button>Submit exam</button>
Wrong or E → FULL. A correct tap with a vague why → TARGETED on steps 3–4. FAST needs name-substring and strict-locator.
What happens when this step runs?
await page.getByRole('button', { name: 'Submit' }).click();The 30-second version
A Playwright test drives the app in a real browser, as a user would. Find elements by role and accessible name: a string name matches part of a name, and an action on a locator matching two elements throws. Locator assertions retry until they hold; sleeps guess. Each test gets a fresh browser context, and a trace records the run.
Find elements the way users do, let assertions wait for the condition, start every test from a clean context, and read the trace.
How it works
Step 1 of 14
Analogy: A mystery diner's scripted visit
A dining-standards agency sends a diner through the front door to eat as any guest would.
| At the restaurant | In a Playwright test |
|---|---|
| ordering by the dish name on the menu, never the kitchen's ticket code | role and accessible name, not CSS (step 2) |
| "Sea bass ceviche" and "Sea bass" both on the menu: asking for "sea bass" makes the waiter ask which | substring names and strictness (steps 3–4) |
| noting the dish when it is set down, glancing up each time the waiter passes, not writing "no food" at ten minutes | a retrying assertion, not a sleep (steps 5–7) |
| each visit booked under a new name, no tab left from the last | a fresh context per test (steps 8–9) |
| one pre-registered membership card, copied for each diner; the copy cannot carry last night's ink hand stamp | saved storage state, without session storage (steps 10–11) |
| the timed notebook with a photo of each course | the trace (steps 12–14) |
In real terms: the test sees only what a user could see and do, so it fails when the user would, and its record says why.
Breaks down when: a restaurant cannot replay an evening; a test re-runs, and a retry can turn a failure into a pass that hides a real fault.
Contrasting case: a component test in jsdom (test-react-testing-library) renders one piece with no server and no real browser; it is faster, but it cannot cut the network or cross pages.
Examples, run for real
4.1 EXH-2130: answers lost when the Wi-Fi drops
ECON-201 candidates lost answers typed during a Wi-Fi drop, while the candidate-app journey test stayed green: it never cut the network. MR !2430 adds a journey that does, using context.setOffline(true), and checks what the server stored.
await page.getByLabel('Answer to question 1').fill('Supply rises');
await expect.poll(() => storedAnswers()).toEqual(['q1']);
await context.setOffline(true);
await page.getByLabel('Answer to question 2').fill('Demand falls');
await context.setOffline(false);
await page.getByRole('button', { name: 'Submit exam' }).click();
await expect(page.getByRole('heading', { name: 'Exam submitted' })).toBeVisible();
await expect.poll(() => storedAnswers()).toEqual(['q1', 'q2']);
EXH-2130 app (a failed save is dropped): journey failed at the last check · server stored: q1
MR !2430 app (a failed save is resent): journey passed · server stored: q1, q2
The old app dropped a save that failed while offline; the fixed app queues it and resends it when the browser is back online. Only a journey that really goes offline tells the two apart (step 1).
4.2 A role locator survives a restyle
A "Flag for review" button is restyled: its class changes and its label moves into a <span>. The same click runs before and after, once by CSS class and once by role.
before the restyle · locator('.btn-flag'): clicked · getByRole('button', { name: 'Flag for review' }): clicked
after the restyle · locator('.btn-flag'): timed out waiting for the locator · getByRole('button', { name: 'Flag for review' }): clicked
The class was a styling detail; the role and the name are what a candidate uses, and they did not change (step 2).
4.3 What a trace holds
A test opens the paper page while the paper service answers 500. The run keeps a trace, and the script lists what it recorded.
test: failed: timed out waiting for the locator
trace actions: BrowserContext.newPage, Frame.goto, Frame.expect
trace network: GET /paper 200 · GET /api/paper 500
trace console errors: Failed to load resource: the server responded with a status of 500 (Internal Server Error) · paper load failed: 500
trace DOM snapshots: present
The failing request and the page's console error are in the trace, beside each action. No re-run with a longer sleep is needed to see why the step failed (step 14).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
The checkout's order-note box autosaves: typing sets the status line to "Saving", and the save's reply sets it to "Saved". On this CI runner the local server holds that reply until the test begins its first assertion.
Which check, placed after the code below, passes once the note is saved, and fails on a run where the reply never comes?
const status = page.getByRole('status');await page.goto('/checkout');await page.getByLabel('Order note').fill('Leave at the door');Question 2 of 5
A checkout spec file holds the three tests below, which run in this order in one worker, with no retries. The shop's /checkout page sends a browser without the session cookie, which signing in sets, to the sign-in page.
What does the run report?
test('sign in', async ({ page }) => { await page.goto('/sign-in'); await page.getByLabel('Email').fill('[email protected]'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('heading', { name: 'Your basket' })).toBeVisible();});test('add a voucher', async ({ page }) => { await page.goto('/checkout'); await page.getByLabel('Voucher').fill('SPRING'); await page.getByRole('button', { name: 'Apply' }).click(); await expect(page.getByText('Voucher applied')).toBeVisible();});test('pay', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay now' }).click(); await expect(page.getByRole('heading', { name: 'Order placed' })).toBeVisible();});Question 3 of 5
A checkout test opens the payment page; the config is below. The payment service answers 503 to the first two page loads of the run and 200 after that.
After the run, which attempts have kept a trace?
// playwright.config.jsexport default defineConfig({ retries: 2, use: { trace: 'retain-on-failure' } });// the testtest('payment page loads', async ({ page }) => { const response = await page.goto('/pay'); expect(response.status()).toBe(200); await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();});Question 4 of 5
A shop's warehouse console has two roles. Its suite has two files of four tests each: stock.spec.js must run as [email protected], and packing.spec.js as [email protected]. Each file logs its account in through the console's form in test.beforeEach, as below. The goal: every test starts as its own role's account, and each account logs in once in the whole run.
Which change meets the goal?
// packing.spec.js; stock.spec.js is the same with account '[email protected]'const account = '[email protected]';test.beforeEach(async ({ page }) => { await page.goto('/login'); await page.getByLabel('Work email').fill(account); await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByRole('heading', { name: `Console: ${account}` })).toBeVisible();});test('queue shows the role', async ({ page }) => { await page.goto('/queue'); await expect(page.getByRole('heading', { name: `Queue for ${account}` })).toBeVisible();});// … three more tests like this one in each fileQuestion 5 of 5
The shop's support-desk suite logs an agent in once in a setup project and loads the saved state in every test, as below, yet every test lands back on the desk's start page. The desk's own login script ends with the app code shown last.
Which line explains why the tests start logged out?
// playwright.config.js (projects){ name: 'setup', testMatch: /auth\.setup\.js/ },{ name: 'desk', dependencies: ['setup'], use: { storageState: '.auth/agent.json' } },// auth.setup.jssetup('agent', async ({ page }) => { await page.goto('/desk/start'); await page.getByLabel('Agent email').fill('[email protected]'); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByRole('heading', { name: 'Open tickets' })).toBeVisible(); await page.context().storageState({ path: '.auth/agent.json' });});// the desk's login script (app code)const { token } = await res.json();sessionStorage.setItem('token', token);location.assign('/tickets');Go deeper
Pro tip
Chase a rare flake with repeats and a trace on every run. npx playwright test <file> --repeat-each=20 --trace=on runs one test twenty times and keeps every trace; open the failing one with npx playwright show-trace. When: a test fails once in many CI runs (steps 12–14). Verify: --repeat-each=4 --trace=on on a test whose server refuses its third page load gave 4 runs, 1 failed and 4 traces kept, one per run.
Source
Every page below was fetched on 2026-10-01 by fetch-sources.mjs and saved as sources/<slug>.txt, which starts with its URL, the date read, the HTTP status, Via: direct (Node fetch, no reader proxy) and the SHA-256 of the saved text. Playwright's docs change with each release; these quotes are as of 2026-10-01, for @playwright/test 1.63.0, and no item is keyed on a quote: every key comes from a run.
- Playwright, Best Practices: https://playwright.dev/docs/best-practices · read 2026-10-01 ·
sources/pw-best-practices.txt(SHA-256de7a19d7637d6d517bb3d6e099e03a1d15d9f977d2f9ac5580bebc5280979e26). "By using web first assertions Playwright will wait until the expected condition is met." Steps 2, 5, 8. - Playwright, Locators: https://playwright.dev/docs/locators · read 2026-10-01 ·
sources/pw-locators.txt(SHA-2568175b0e9c2054a3162f1c0c3d0f007f5b0c366ce8d1c86e0bf46b085d1b2ea1b). "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches." Steps 2, 4. - Playwright, Page API (
getByRole,waitForTimeout): https://playwright.dev/docs/api/class-page · read 2026-10-01 ·sources/pw-api-page.txt(SHA-256530868d40ee2cfd03721baf6f091b5adffa6bdbb5e48aa7e319c4fa3f64f0a26). Onname: "By default, matching is case-insensitive and searches for a substring, use exact to control this behavior." OnwaitForTimeout: "Tests that wait for time are inherently flaky." Steps 3, 7. - Playwright, Locator API (
waitFor,isVisible): https://playwright.dev/docs/api/class-locator · read 2026-10-01 ·sources/pw-api-locator.txt(SHA-256042033d580a7a698576ad7ebe2fb4eb2cdd96a669e22254d92f3bea3d7c068e4).waitFor"Defaults to 'visible'"; "locator.isVisible() does not wait for the element to become visible and returns immediately." Step 6. - Playwright, Assertions: https://playwright.dev/docs/test-assertions · read 2026-10-01 ·
sources/pw-test-assertions.txt(SHA-2567ce6d4c878559a7d667dea756afc7d297f0cb99b98332f7de2b69f658c7a415e). "The following assertions will retry until the assertion passes, or the assertion timeout is reached. Note that retrying assertions are async, so you must await them." Steps 5–6. - Playwright, Isolation: https://playwright.dev/docs/browser-contexts · read 2026-10-01 ·
sources/pw-browser-contexts.txt(SHA-256ab78576d2e78f7466ac32e7b7c7b06023bfa413e09ee07c79135aaa72b3197c6). "This means that each test has its own local storage, session storage, cookies etc." Steps 1, 8. - Playwright, Retries: https://playwright.dev/docs/test-retries · read 2026-10-01 ·
sources/pw-test-retries.txt(SHA-2568d53dabe80236473f30d781e634ad01aec7af1fdc8cd7c32bb1f6ab581aec968). "Should any test fail, Playwright Test will discard the entire worker process along with the browser and will start a new one." Steps 9, 12. - Playwright, Authentication: https://playwright.dev/docs/auth · read 2026-10-01 ·
sources/pw-auth.txt(SHA-256c7b8577462174b4da22eccb02c63bcf97ef1b1b9e06f4a419eb9a312e44171c1). "Authenticate once in the setup project, save the authentication state, and then reuse it to bootstrap each test already authenticated." "Playwright does not provide API to persist session storage". Steps 10–11. - Playwright, TestOptions API (
trace): https://playwright.dev/docs/api/class-testoptions · read 2026-10-01 ·sources/pw-api-testoptions.txt(SHA-25625d54432dfada38d98593efb2ae2176ace7fe905e26116a054fa017411745d5a). "'on-first-retry': Record and keep a trace only for the first retry of a test." Step 13. - Playwright, Trace viewer: https://playwright.dev/docs/trace-viewer · read 2026-10-01 ·
sources/pw-trace-viewer.txt(SHA-25611edcb8055ee7df4aa6695d57d2253571a23d8612b4463852afba8839f7ed111). "Traces are a great way for debugging your tests when they fail on CI." Step 14 and the pro tip. - Playwright, BrowserContext API (
setOffline): https://playwright.dev/docs/api/class-browsercontext · read 2026-10-01 ·sources/pw-api-browsercontext.txt(SHA-2560bdf5a1190022d9055841539f4855f613211d18fc39e62bb63c211d66375816f). - Also fetched, not quoted: Actionability (https://playwright.dev/docs/actionability,
sources/pw-actionability.txt) and Test use options (https://playwright.dev/docs/test-use-options,sources/pw-test-use-options.txt), both read 2026-10-01. - Cross-pack:
test-react-testing-libraryowns component queries andwaitForin jsdom;ui-semantics-ariaowns accessible-name computation;test-shape-pyramid-trophyandjudgment-testing-worthown which journeys deserve an end-to-end test. This lesson links them and does not re-teach them.
Related lessons
Checked against the tools and docs as of 2026-10-01.