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.
- 1Setting up
- 2A first end-to-end test
- 3Locators are lazy and strict
- 4Auto-waiting: actions
- 5Web-first assertions: assertions that wait
- 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
npm init playwright@latestThe 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:
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
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:
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.
Web-first assertions: assertions that wait
The same idea applies to checking results. Playwright’s expect on a locator is asynchronous and retrying:
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
webServeroption starts your dev server before the tests run. - Prefer user-facing locators (
getByRole,getByLabel,getByText) and narrow them withfilteror chaining instead of CSS paths. - Actions such as
clickandfillauto-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.