EduFlow
Testing JavaScript Apps
0%

Section 4 · End-to-End with Playwright

Locators, Web-First Assertions and Auto-Waiting

20 min3 min readChoose wisely

Lesson at a glance

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.

  1. 1Setting up
  2. 2A first end-to-end test
  3. 3Locators are lazy and strict
  4. 4Auto-waiting: actions
  5. 5Web-first assertions: assertions that wait
  6. 6What belongs in an end-to-end test

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

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.

Setting up

Terminal
npm init playwright@latest

The wizard adds @playwright/test, a playwright.config.ts, 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:

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

webServer runs npm run dev, waits until the URL responds, runs the tests, then stops it. baseURL lets tests call page.goto('/groups/lisbon'). Run the suite with npx playwright test.

A first end-to-end test

e2e/settle-up.spec.ts
import { test, expect } from '@playwright/test';

test('adding an expense updates the balances', async ({ page }) => {
  await page.goto('/groups/lisbon');

  await page.getByLabel('Description').fill('Dinner');
  await page.getByLabel('Amount').fill('30.00');
  await page.getByRole('button', { name: 'Add expense' }).click();

  const balances = page.getByRole('list', { name: 'Balances' });
  await expect(balances.getByRole('listitem')).toHaveText([
    'Ana is owed $15.00',
    'Ben owes $15.00',
  ]);
});

page is a fresh browser tab, given to the test as a fixture (more on those next lesson). The locators should look familiar: Playwright’s getByRole, getByLabel, getByText, getByPlaceholder, getByAltText, getByTitle and getByTestId follow the same priority idea as Testing Library, for the same reason.

Locators are lazy and strict

A locator is a description of how to find an element, not the element itself. page.getByRole('button', { name: 'Add expense' }) 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.

Locators are also strict: if an action’s locator matches two elements, Playwright throws instead of clicking the first. Narrow with chaining and filter:

TypeScript
const taxiRow = page.getByRole('listitem').filter({ hasText: 'Taxi' });
await taxiRow.getByRole('button', { name: 'Remove' }).click();

That reads like the instruction you’d give a person (“remove the taxi row”), and it survives reordering. nth(2) and first() exist, but they break the moment the order changes; use them only when order is the point.

Auto-waiting: actions

Before click(), Playwright waits until the element is visible, stable (not mid-animation), enabled, and actually receives events (not covered by a spinner or a cookie banner). fill() 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.

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.

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. click() visible stable receives events enabled click toHaveText() re-read, no match matches gives up at 5 s
Actions wait until the element is ready; assertions retry until they pass

Web-first assertions: assertions that wait

The same idea applies to checking results. Playwright’s expect on a locator is asynchronous and retrying:

TypeScript
await expect(page.getByRole('status')).toHaveText('Everyone is settled up');
await expect(page.getByRole('listitem')).toHaveCount(0);
await expect(page).toHaveURL(/\/groups\/lisbon\/settled/);
await expect(page.getByRole('button', { name: 'Settle up' })).toBeDisabled();

Each one re-checks the page until it matches, for up to 5 seconds by default. toHaveText 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.

What belongs in an end-to-end test

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 connection 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.

The exercise asks you to pick the locator that will survive the next redesign. Next lesson: keeping tests independent of each other.

Key takeaways

  • Playwright drives real Chromium, Firefox and WebKit, and its webServer option starts your dev server before the tests run.
  • Prefer user-facing locators (getByRole, getByLabel, getByText) and narrow them with filter or chaining instead of CSS paths.
  • Actions such as click and fill auto-wait until the element is visible, stable, enabled and able to receive events.
  • Web-first assertions like await expect(locator).toHaveText(...) retry until they pass or time out (5 seconds by default); reading a value and asserting on it once does not.

Further reading

Check your understanding

3 questions

Question 1 of 3

Which assertion keeps working when the balance updates 300 ms after the click?

Question 2 of 3

await page.getByRole('button', { name: 'Remove' }).click() fails with a strict mode violation. What does that mean?

Question 3 of 3

What does Playwright check before it performs click() on a locator?

Practice

Choose wisely

Pick the locator that survives a redesign

Splitwise-lite’s expense list renders like this:

HTML
<ul aria-label="Expenses" class="mt-4 space-y-2">
  <li class="row">Dinner: $45.50 <button class="btn btn-ghost">Remove</button></li>
  <li class="row">Taxi: $18.00 <button class="btn btn-ghost" data-testid="remove-e2">Remove</button></li>
  <li class="row">Museum: $24.00 <button class="btn btn-ghost">Remove</button></li>
</ul>

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 the Remove button of the Taxi expense is most likely to keep working, and to fail only if users really couldn’t remove the taxi?

Pick the locator that survives a redesign