{"id":"l-tj-4-1","courseId":"testing-javascript","lang":"en","kind":"lesson","summary":"Set up Playwright for a Vite app, find elements with user-facing locators, and write web-first assertions that wait for the page instead of racing it.","html":"<p>Everything so far ran in Node, with jsdom pretending to be a browser. That covers most logic and most UI behaviour, but not all of it: real layout, real routing, the real server, a real CSS bug that hides the “Settle up” button on Safari. For the few flows where that matters, you drive a real browser. In 2026 the default tool for that in JavaScript is Playwright.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Setting up</h2>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">npm</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> init</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> playwright@latest</span></span></code></pre></div>\n<p>The wizard adds <code dir=\"ltr\">@playwright/test</code>, a <code dir=\"ltr\">playwright.config.ts</code>, an example test and (optionally) a GitHub Actions workflow, and downloads the browsers. Point it at your Vite app and let it start the dev server for you:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"ts\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">playwright.config.ts</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { defineConfig, devices } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@playwright/test'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> default</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> defineConfig</span><span style=\"color:#953800;--shiki-dark:#FFA657\">({</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  testDir: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'./e2e'</span><span style=\"color:#953800;--shiki-dark:#FFA657\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  fullyParallel: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">true</span><span style=\"color:#953800;--shiki-dark:#FFA657\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  retries: process.env.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">CI</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ?</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 2</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> :</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#953800;--shiki-dark:#FFA657\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  use: {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    baseURL: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'http://localhost:5173'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    trace: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'on-first-retry'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span><span style=\"color:#953800;--shiki-dark:#FFA657\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  projects: [</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'chromium'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, use: { </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">devices[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Desktop Chrome'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] } },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'webkit'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, use: { </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">devices[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Desktop Safari'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] } },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ]</span><span style=\"color:#953800;--shiki-dark:#FFA657\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  webServer: {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    command: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'npm run dev'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    url: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'http://localhost:5173'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    reuseExistingServer: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">process.env.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">CI</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span><span style=\"color:#953800;--shiki-dark:#FFA657\">,</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">})</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre></div>\n<p><code dir=\"ltr\">webServer</code> runs <code dir=\"ltr\">npm run dev</code>, waits until the URL responds, runs the tests, then stops it. <code dir=\"ltr\">baseURL</code> lets tests call <code dir=\"ltr\">page.goto('/groups/lisbon')</code>. Run the suite with <code dir=\"ltr\">npx playwright test</code>.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">A first end-to-end test</h2>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"ts\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">e2e/settle-up.spec.ts</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { test, expect } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@playwright/test'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'adding an expense updates the balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">page</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">goto</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'/groups/lisbon'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByLabel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">fill</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Dinner'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByLabel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">fill</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'30.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Add expense'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">click</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> balances</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(balances.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'listitem'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveText</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Ana is owed $15.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Ben owes $15.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ]);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p><code dir=\"ltr\">page</code> is a fresh browser tab, given to the test as a <strong>fixture</strong> (more on those next lesson). The locators should look familiar: Playwright’s <code dir=\"ltr\">getByRole</code>, <code dir=\"ltr\">getByLabel</code>, <code dir=\"ltr\">getByText</code>, <code dir=\"ltr\">getByPlaceholder</code>, <code dir=\"ltr\">getByAltText</code>, <code dir=\"ltr\">getByTitle</code> and <code dir=\"ltr\">getByTestId</code> follow the same priority idea as Testing Library, for the same reason.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Locators are lazy and strict</h2>\n<p>A locator is a <strong>description</strong> of how to find an element, not the element itself. <code dir=\"ltr\">page.getByRole('button', { name: 'Add expense' })</code> finds nothing until you act on it, and it finds it again every time, so a re-render that replaces the button doesn’t leave you holding a stale reference.</p>\n<p>Locators are also <strong>strict</strong>: if an action’s locator matches two elements, Playwright throws instead of clicking the first. Narrow with chaining and <code dir=\"ltr\">filter</code>:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"ts\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">TypeScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> taxiRow</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'listitem'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ hasText: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Taxi'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> taxiRow.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Remove'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">click</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span></code></pre></div>\n<p>That reads like the instruction you’d give a person (“remove the taxi row”), and it survives reordering. <code dir=\"ltr\">nth(2)</code> and <code dir=\"ltr\">first()</code> exist, but they break the moment the order changes; use them only when order <em>is</em> the point.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Auto-waiting: actions</h2>\n<p>Before <code dir=\"ltr\">click()</code>, Playwright waits until the element is <strong>visible</strong>, <strong>stable</strong> (not mid-animation), <strong>enabled</strong>, and actually <strong>receives events</strong> (not covered by a spinner or a cookie banner). <code dir=\"ltr\">fill()</code> waits for visible, enabled and editable. Then it acts. If the conditions never hold, the action fails after the timeout with a message saying which check failed.</p>\n<p>That is why end-to-end tests in Playwright rarely need explicit waits. The button that appears after the group loads is clicked when it appears.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 230\" role=\"img\" aria-labelledby=\"l-tj-4-1-t1\"><defs><marker id=\"arrow-l-tj-4-1-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-tj-4-1-t1\">Top: click waits for visible, stable, receives events and enabled, then clicks. Bottom: toHaveText re-reads the element repeatedly until the text matches or 5 seconds pass.</title>\n  <text class=\"d-code\" x=\"20\" y=\"44\">click()</text>\n  <rect class=\"d-box-accent\" x=\"110\" y=\"24\" width=\"80\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"150\" y=\"45\" text-anchor=\"middle\">visible</text>\n  <rect class=\"d-box-accent\" x=\"200\" y=\"24\" width=\"80\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"240\" y=\"45\" text-anchor=\"middle\">stable</text>\n  <rect class=\"d-box-accent\" x=\"290\" y=\"24\" width=\"130\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"355\" y=\"45\" text-anchor=\"middle\">receives events</text>\n  <rect class=\"d-box-accent\" x=\"430\" y=\"24\" width=\"90\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"475\" y=\"45\" text-anchor=\"middle\">enabled</text>\n  <path class=\"d-arrow\" d=\"M524 40 L566 40\" marker-end=\"url(#arrow-l-tj-4-1-1)\"/>\n  <rect class=\"d-box-success\" x=\"570\" y=\"24\" width=\"90\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"615\" y=\"45\" text-anchor=\"middle\">click</text>\n  <text class=\"d-code\" x=\"20\" y=\"144\">toHaveText()</text>\n  <path class=\"d-line\" d=\"M150 140 L520 140\"/>\n  <circle class=\"d-dot\" cx=\"170\" cy=\"140\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"240\" cy=\"140\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"310\" cy=\"140\" r=\"6\"/>\n  <text class=\"d-label-muted\" x=\"240\" y=\"120\" text-anchor=\"middle\">re-read, no match</text>\n  <rect class=\"d-box-success\" x=\"350\" y=\"124\" width=\"110\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"405\" y=\"145\" text-anchor=\"middle\">matches</text>\n  <text class=\"d-label-muted\" x=\"520\" y=\"180\" text-anchor=\"middle\">gives up at 5 s</text>\n  <path class=\"d-line d-dashed\" d=\"M520 110 L520 165\"/>\n</svg><figcaption>Actions wait until the element is ready; assertions retry until they pass</figcaption></figure>\n<h2 id=\"sec-5\" tabindex=\"-1\">Web-first assertions: assertions that wait</h2>\n<p>The same idea applies to checking results. Playwright’s <code dir=\"ltr\">expect</code> on a <strong>locator</strong> is asynchronous and retrying:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"ts\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">TypeScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveText</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Everyone is settled up'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'listitem'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveCount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(page).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveURL</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">/</span><span style=\"color:#116329;--shiki-light-font-weight:bold;--shiki-dark:#7EE787;--shiki-dark-font-weight:bold\">\\/</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">groups</span><span style=\"color:#116329;--shiki-light-font-weight:bold;--shiki-dark:#7EE787;--shiki-dark-font-weight:bold\">\\/</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">lisbon</span><span style=\"color:#116329;--shiki-light-font-weight:bold;--shiki-dark:#7EE787;--shiki-dark-font-weight:bold\">\\/</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">settled/</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(page.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Settle up'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> })).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeDisabled</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span></code></pre></div>\n<p>Each one re-checks the page until it matches, for up to 5 seconds by default. <code dir=\"ltr\">toHaveText</code> with an array (as in the first test) checks every matched element’s text, in order, which is a compact way to assert a whole list.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Reading values out and asserting on them</span></p>\n<p><code dir=\"ltr\">expect(await page.getByRole('status').textContent()).toBe('Everyone is settled up')</code> looks equivalent, and it isn’t. <code dir=\"ltr\">textContent()</code> reads once, right now; the generic <code dir=\"ltr\">toBe</code> never retries. If the status updates 50 ms later, the test fails, sometimes. That “sometimes” is how flaky suites are born. Keep the <code dir=\"ltr\">await</code> <strong>outside</strong> <code dir=\"ltr\">expect</code>, on a locator, so the assertion owns the waiting.</p>\n</aside>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Let Playwright write the first draft</span></p>\n<p><code dir=\"ltr\">npx playwright codegen localhost:5173</code> opens a browser and records your clicks as test code, choosing role-based locators where it can. It’s a fast way to discover the right locator for an awkward element; then edit the result so it asserts outcomes, not every click.</p>\n</aside>\n<h2 id=\"sec-6\" tabindex=\"-1\">What belongs in an end-to-end test</h2>\n<p>Because every test here starts a browser context, loads the app and talks to a real server, an end-to-end test costs seconds where a unit test costs milliseconds. Spend that budget on flows where the <em>connection</em> between pieces is the risk: signing in, adding an expense and seeing balances change, settling up, anything involving money or data loss. Don’t use it to check every validation message on the amount field; the component tests from section 3 already do that a hundred times faster. A healthy Splitwise-lite suite might have five to fifteen end-to-end tests and several hundred unit and component tests. If an end-to-end test fails, ask first whether a lower-level test should have caught it.</p>\n<p>The exercise asks you to pick the locator that will survive the next redesign. Next lesson: keeping tests independent of each other.</p>\n","toc":[{"id":"sec-1","text":"Setting up","level":2},{"id":"sec-2","text":"A first end-to-end test","level":2},{"id":"sec-3","text":"Locators are lazy and strict","level":2},{"id":"sec-4","text":"Auto-waiting: actions","level":2},{"id":"sec-5","text":"Web-first assertions: assertions that wait","level":2},{"id":"sec-6","text":"What belongs in an end-to-end test","level":2}],"takeaways":["Playwright drives real Chromium, Firefox and WebKit, and its <code dir=\"ltr\">webServer</code> option starts your dev server before the tests run.","Prefer user-facing locators (<code dir=\"ltr\">getByRole</code>, <code dir=\"ltr\">getByLabel</code>, <code dir=\"ltr\">getByText</code>) and narrow them with <code dir=\"ltr\">filter</code> or chaining instead of CSS paths.","Actions such as <code dir=\"ltr\">click</code> and <code dir=\"ltr\">fill</code> auto-wait until the element is visible, stable, enabled and able to receive events.","Web-first assertions like <code dir=\"ltr\">await expect(locator).toHaveText(...)</code> retry until they pass or time out (5 seconds by default); reading a value and asserting on it once does not."],"further":[{"title":"Playwright — Locators","url":"https://playwright.dev/docs/locators"},{"title":"Playwright — Assertions","url":"https://playwright.dev/docs/test-assertions"},{"title":"Playwright — Auto-waiting","url":"https://playwright.dev/docs/actionability"},{"title":"Playwright — Web server","url":"https://playwright.dev/docs/test-webserver"}],"quiz":[{"q":"<p>Which assertion keeps working when the balance updates 300 ms after the click?</p>","options":[{"text":"<code dir=\"ltr\">expect(await page.getByRole('status').textContent()).toBe('Ben owes $5.00')</code>","why":"This reads the text once, immediately; if the update hasn’t landed yet, it fails. Nothing retries."},{"text":"<code dir=\"ltr\">await expect(page.getByRole('status')).toHaveText('Ben owes $5.00')</code>","why":"Correct. A web-first assertion re-reads the element until the text matches or the timeout passes."},{"text":"<code dir=\"ltr\">await page.waitForTimeout(500); expect(await page.getByRole('status').isVisible()).toBe(true)</code>","why":"The sleep is a guess, and the assertion doesn’t check the text at all."}],"answer":1},{"q":"<p><code dir=\"ltr\">await page.getByRole('button', { name: 'Remove' }).click()</code> fails with a strict mode violation. What does that mean?</p>","options":[{"text":"The button is disabled, so Playwright refuses to click.","why":"A disabled button makes the click wait and then time out; strict mode is about how many elements match."},{"text":"The test is running in a browser with strict security settings.","why":"Strict mode is a locator rule, not a browser setting."},{"text":"The locator matches more than one element, so Playwright won’t guess which to act on.","why":"Correct. Narrow it, for example <code dir=\"ltr\">page.getByRole('listitem').filter({ hasText: 'Taxi' }).getByRole('button', { name: 'Remove' })</code>."}],"answer":2},{"q":"<p>What does Playwright check before it performs <code dir=\"ltr\">click()</code> on a locator?</p>","options":[{"text":"That the element is visible, stable (not animating), enabled and not covered by another element.","why":"Correct. Those actionability checks are what make most explicit waits unnecessary."},{"text":"Only that the element exists in the DOM.","why":"Existing isn’t enough; a hidden or covered button can’t be clicked by a user, and Playwright waits for it to be clickable."},{"text":"Nothing; it clicks immediately and the test must wait beforehand.","why":"That’s how older tools worked; Playwright auto-waits for actions."}],"answer":0}],"exercise":{"kind":"choice","multiple":false,"options":["css-path","nth","role-filter","text-only","test-id"],"correct":["role-filter"],"title":"Pick the locator that survives a redesign","prompt":"<p>Splitwise-lite’s expense list renders like this:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">HTML</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ul</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> aria-label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Expenses\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"mt-4 space-y-2\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"row\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Dinner: $45.50 &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"btn btn-ghost\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Remove&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"row\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Taxi: $18.00 &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"btn btn-ghost\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> data-testid</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"remove-e2\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Remove&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"row\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Museum: $24.00 &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"btn btn-ghost\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Remove&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">button</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ul</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n<p>Next sprint, a designer will switch the list to a card grid, change every class, and sort expenses by date instead of insertion order. Which locator for <strong>the Remove button of the Taxi expense</strong> is most likely to keep working, and to fail only if users really couldn’t remove the taxi?</p>\n","hints":["Rule out anything that depends on classes, DOM nesting or position: all three are about to change.","Prefer what a user perceives: the button’s role and name, scoped to the row that mentions Taxi."],"explanation":"<p><code dir=\"ltr\">page.getByRole('listitem').filter({ hasText: 'Taxi' }).getByRole('button', { name: 'Remove' })</code> describes the target the way a person would: the Remove button in the taxi row. It doesn’t care about classes, nesting or order, so the redesign leaves it intact, and it fails if the row stops being a list item with an accessible Remove button, which is a real regression for keyboard and screen reader users. If the redesign uses cards that aren’t list items any more, the test fails loudly and points at a genuine accessibility change, which is worth a conversation. The CSS path and <code dir=\"ltr\">nth(1)</code> break on the first class change or re-sort; the test id survives restyling but proves nothing about what users can see or reach, and only one row has it.</p>\n","checkLabels":{},"optionText":{"css-path":{"text":"<code dir=\"ltr\">page.locator('ul.space-y-2 &gt; li.row:nth-child(2) &gt; button.btn-ghost')</code>","why":"It encodes classes, nesting and position, all of which the redesign changes, so it breaks even though removing the taxi still works."},"nth":{"text":"<code dir=\"ltr\">page.getByRole('button', { name: 'Remove' }).nth(1)</code>","why":"It relies on the taxi being second. After sorting by date it would silently click a different expense’s Remove button, which is worse than failing."},"role-filter":{"text":"<code dir=\"ltr\">page.getByRole('listitem').filter({ hasText: 'Taxi' }).getByRole('button', { name: 'Remove' })</code>","why":"Correct. It finds the button by role and name inside the row that mentions Taxi, so classes, layout and order can change freely."},"text-only":{"text":"<code dir=\"ltr\">page.getByText('Remove')</code>","why":"It matches three buttons, so any action on it fails with a strict mode violation, and it says nothing about which expense you meant."},"test-id":{"text":"<code dir=\"ltr\">page.getByTestId('remove-e2')</code>","why":"It survives restyling, but test ids are invisible to users and the last resort: the button could lose its label or be hidden from assistive tech and this would still pass."}}},"words":713,"readMinutes":3}