End-to-end tests with Playwright
Check the flows that matter in a real browser with Playwright, with role-based locators, retrying assertions, a trace for every failure in CI, and the same approach for Vue and Nuxt apps.
What you'll learn
When you finish, you can do these.- 1Let Playwright build and serve your app for the tests
- 2Write a test that clicks through a flow like a visitor
- 3Run the tests in CI and open the trace of a failure
- 4Test a Nuxt app without losing an early click
Picture a sticker shop, a small online shop: a visitor adds a sticker to the cart, opens the checkout page, types an email and presses Pay. Unit tests, run with a tool called Vitest, check its pieces one at a time: a component here, a function there. None of them can tell you that the finished shop loads and that its checkout works in a real browser on a phone.
An end-to-end test opens the built app in a real browser and walks that flow like a visitor. Playwright, a tool that drives real browsers, runs these tests. You need one idea from Testing Library and browser mode: a test finds an element the way a person does, by its role, such as button or link, and by its label. The Code tab holds the shop's Playwright config, its checkout test and the workflow file that runs them on GitHub.
How a Playwright test runs
Every end-to-end run follows the same five steps.
- webServerbuild + preview
- page.goto
- locator actions
- expect retries
- trace on failure
Playwright's settings live in playwright.config.ts, the first file in the Code tab. Its webServer option does the first step: before any test, it builds the app, serves the build with npm run preview, and waits until the URL answers.
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
},
baseURL, the same address in the config, lets a test open the home page with page.goto('/').
Each entry in projects runs every test again with other settings: here a desktop Chrome and a Pixel 7 phone.
Each end-to-end test takes seconds, so keep them for the few flows that must never break, such as checkout.
Write the test like a visitor
The checkout test, e2e/checkout.spec.ts in the Code tab, does what a visitor does and checks what a visitor sees.
Each test receives a page, a fresh browser tab with its own cookies and storage, so tests never share a cart.
Locators, Playwright's pointers to elements, find them by role, label and text, and actions such as click() and fill() wait until the element is visible and enabled. The first lines open the shop, add a sticker and check that the cart badge in the page header, whose role is banner, shows 1 item.
await page.goto('/')
await page.getByRole('button', { name: 'Add a sticker' }).click()
await expect(page.getByRole('banner').getByText('1 item')).toBeVisible()
Assertions, the checks written with expect(locator), retry until they pass or 5 seconds run out, so the test waits exactly as long as the app needs. They return a promise, so the await is not optional: if the test ends while one is still running, Playwright 1.64 fails it with Error: Test ended.
The rest of the test clicks Checkout, fills in Email, presses Pay and expects the Thank you heading.
page.route() lets a test answer the page's network requests itself. A beforeEach hook, code that runs before each test in the file, answers the shop's order request, /api/orders, with status 201, so no real order is placed. Answer requests this way for anything you do not own, such as a payment provider.
A second test runs axe, an accessibility checker, on the checkout page through @axe-core/playwright. Its analyze() call lists the rules the page breaks in results.violations, which the test expects to be empty: remove the Email field's label, and it fails with label: Form elements must have labels.
Run it in CI
CI, short for continuous integration, is a server that runs your tests each time you push new code to GitHub. There a workflow, a file in .github/workflows/, tells it what to run. The one in the Code tab runs Vitest, then installs the Chromium browser with the system libraries it needs, runs Playwright and uploads the report.
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }} # upload the report when tests fail too
The if line uploads the report even when tests fail, as an artifact named playwright-report: a file kept with the run, ready to download.
CI also sets the CI environment variable, a value any program can read, so Playwright's config retries a failed test up to two times, runs one test at a time and starts a fresh server.
A trace is a recording of a test: every action, the page before and after it, network calls and console output. trace: 'on-first-retry' records one only when a failed test is retried, which needs retries above 0. Download the report, open it with npx playwright show-report, and click the trace to step through the failure.
Nuxt is a framework on top of Vue. For a Nuxt app, the same tests need three changes. Import test and expect from @nuxt/test-utils/playwright, part of @nuxt/test-utils, the package that runs Nuxt inside tests (Testing Nuxt apps). In the config, set use: { nuxt: { rootDir } } with the app's folder as rootDir, so @nuxt/test-utils builds and serves the app. Each test then receives goto next to page: open pages with await goto('/', { waitUntil: 'hydration' }).
Nuxt's server sends each page as ready-made HTML. Vue then takes the page over and attaches the code that runs on each click, a step called hydration. Playwright can click before hydration, and that click is lost. The test turns flaky: it passes or fails by chance. Waiting for hydration removes the flake.
What goes wrong
Two common mistakes break end-to-end tests: a locator that matches twice, and a fixed wait.
After one click on Add a sticker, the shop shows 1 item twice: in the header badge and in the cart summary. A text locator matches both.
await expect(page.getByText('1 item')).toBeVisible()
Playwright refuses to guess: Error: strict mode violation: getByText('1 item') resolved to 2 elements, followed by both elements. This is strict mode: every action and assertion needs exactly one match. Narrow the locator to the part of the page you mean.
await expect(page.getByRole('banner').getByText('1 item')).toBeVisible()
Inside the header, only the badge says 1 item, so the assertion passes.
The second mistake is waiting for a fixed time.
await page.getByRole('button', { name: 'Add a sticker' }).click()
await page.waitForTimeout(1000)
expect(await page.getByRole('banner').getByText('1 item').isVisible()).toBe(true)
isVisible() checks once. The test wastes a second on a fast laptop and fails with Expected: true, Received: false on a busy CI runner where the update takes longer. A retrying assertion replaces both lines.
await page.getByRole('button', { name: 'Add a sticker' }).click()
await expect(page.getByRole('banner').getByText('1 item')).toBeVisible()
It waits as long as the badge needs, and no longer.
What to remember
- Keep end-to-end tests for the flows that must never break, and let
webServerbuild and serve the real app for them. - Find elements by role, label and text, and narrow a locator until it matches exactly one element.
- Always
await expect(locator); never wait withwaitForTimeout(). trace: 'on-first-retry'needsretriesin CI, and the report must be uploaded as an artifact to be seen.- In a Nuxt app, use
@nuxt/test-utils/playwrightandgoto(url, { waitUntil: 'hydration' })before clicking.
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
npx playwright test --ui. The UI mode window lists both tests; run the checkout test and click each step in the timeline: the page snapshot shows the badge change from0 itemsto1 item. - Change the expected heading in the test to
Thanks for your orderand runnpx playwright test --retries=1. The test fails twice, and the report lists a trace for retry #1: open it withnpx playwright show-reportand find the failedtoBeVisible, with the page as it looked. Run again with--retries=0: no trace is recorded. Put the heading back. - Run
npx playwright test --project=mobile. Both tests pass on the Pixel 7 profile; if a header item is hidden on narrow screens, this is the run that tells you.