Skip to content

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?

Code for this question
await page.getByRole('button', { name: 'Submit' }).click();
Pick one

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

Real browser, real app. A test drives pages inside a browser context, which holds that session's cookies and storage, like a fresh profile.

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?

Code for this question
const status = page.getByRole('status');await page.goto('/checkout');await page.getByLabel('Order note').fill('Leave at the door');
Pick one

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?

Code for this question
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();});
Pick one

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?

Code for this question
// 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();});
Pick one

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?

Code for this question
// 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 file
Pick one

Question 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?

Code for this question
// 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');
Pick one

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-256 de7a19d7637d6d517bb3d6e099e03a1d15d9f977d2f9ac5580bebc5280979e26). "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-256 8175b0e9c2054a3162f1c0c3d0f007f5b0c366ce8d1c86e0bf46b085d1b2ea1b). "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-256 530868d40ee2cfd03721baf6f091b5adffa6bdbb5e48aa7e319c4fa3f64f0a26). On name: "By default, matching is case-insensitive and searches for a substring, use exact to control this behavior." On waitForTimeout: "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-256 042033d580a7a698576ad7ebe2fb4eb2cdd96a669e22254d92f3bea3d7c068e4). 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-256 7ce6d4c878559a7d667dea756afc7d297f0cb99b98332f7de2b69f658c7a415e). "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-256 ab78576d2e78f7466ac32e7b7c7b06023bfa413e09ee07c79135aaa72b3197c6). "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-256 8d53dabe80236473f30d781e634ad01aec7af1fdc8cd7c32bb1f6ab581aec968). "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-256 c7b8577462174b4da22eccb02c63bcf97ef1b1b9e06f4a419eb9a312e44171c1). "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-256 25d54432dfada38d98593efb2ae2176ace7fe905e26116a054fa017411745d5a). "'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-256 11edcb8055ee7df4aa6695d57d2253571a23d8612b4463852afba8839f7ed111). "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-256 0bdf5a1190022d9055841539f4855f613211d18fc39e62bb63c211d66375816f).
  • 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-library owns component queries and waitFor in jsdom; ui-semantics-aria owns accessible-name computation; test-shape-pyramid-trophy and judgment-testing-worth own which journeys deserve an end-to-end test. This lesson links them and does not re-teach them.

Checked against the tools and docs as of 2026-10-01.