Skip to content
TutorialTesting

End-to-end tests with Playwright for a Vue app

Unit tests tell you a function works. An end-to-end test tells you a visitor can sign in. Here is the whole setup for a Vue app, from npm init playwright to a green job in CI.

vuejs.school team11 Dec 20258 min readPlaywright 1.57 · Vite 7.2

A unit test checks one function. A component test mounts one component in a fake DOM. Neither tells you whether a person can open your site, sign in and see their dashboard. An end-to-end test does: it opens the built app in a real browser and clicks through a flow the way a visitor would.

You will add Playwright 1.57, published on 25 November, to a Vue app made with create-vue on Vite 7.2, and test its home page and sign-in flow against the production build. When a test fails in CI, you will be able to open a trace and see why. A Nuxt app is tested the same way; only the server command and the port change.

Install Playwright

Run the initialiser in the project folder:

npm init playwright@latest

Answer its questions like this:

  1. TypeScript or JavaScript: TypeScript. A project that already has a tsconfig.json, as a TypeScript create-vue app does, skips this question.
  2. Where to put the tests: type e2e, so the browser tests stay apart from your Vitest files. The Vitest config that create-vue writes already excludes e2e/**, so Vitest will not try to run them.
  3. Add a GitHub Actions workflow: yes.
  4. Install the Playwright browsers: yes.

On Linux it also offers to install the system libraries the browsers need, which requires sudo.

The command adds @playwright/test to your devDependencies and writes playwright.config.ts, an example test in e2e/example.spec.ts and a workflow in .github/workflows/playwright.yml. The getting started page lists the requirements: Node 20, 22 or 24, and a recent Windows, macOS, Debian or Ubuntu release.

Point the config at vite preview

Test the production build, not the dev server. The dev server serves unbundled modules and skips the minifier, so a problem that only the build introduces never shows up there. vite preview serves the built dist folder on port 4173: the same files your host will serve. Replace the generated config with this one:

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

export default defineConfig({
  testDir: 'e2e',
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:4173',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npm run build && npm run preview',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile', use: { ...devices['Pixel 7'] } },
  ],
})

The webServer block makes the suite self-contained. Before the first test, Playwright runs command, polls url until it answers, and only then starts the tests. It stops the server at the end. The other settings:

  • timeout is in milliseconds. The default is 60 seconds, and a build plus a server start can take longer than that on a laptop, so give it two minutes.
  • reuseExistingServer: !process.env.CI reuses a preview server you already have running locally, while CI always starts a fresh one.
  • baseURL lets every test write page.goto('/') instead of the full address.
  • trace: 'on-first-retry' records a trace only when a test fails and runs again, which costs nothing on a green run.
  • retries is 2 in CI and 0 locally. In CI, a test that fails and then passes on a retry is marked flaky in the report instead of failing the job. Locally, you see the failure straight away.
  • The two projects run every test twice, once in desktop Chrome and once with the screen size, touch and user agent of a Pixel 7.

The web server guide describes each option, including a wait field new in 1.57 that matches a regular expression against the server's output. vite preview does not need it, because it only opens the port when it is ready.

For a Nuxt app, set the command to npm run build && node .output/server/index.mjs and use port 3000 in url and baseURL.

Write a first test for the home page

The example test that the initialiser wrote visits playwright.dev. Delete it and write one against your own app:

e2e/home.spec.ts
import { expect, test } from '@playwright/test'

test('the home page shows the headline', async ({ page }) => {
  await page.goto('/')
  await expect(page.getByRole('heading', { level: 1, name: 'Plan your week' })).toBeVisible()
  await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible()
})

Every test receives a fresh page: a browser tab with no cookies and no storage from the previous test. getByRole('heading', { level: 1, name: 'Plan your week' }) finds the <h1> by what it is and what it says, the way a screen reader does. expect(locator).toBeVisible() is a retrying assertion: it checks again and again until the element is visible or five seconds pass.

Run the suite from the project folder:

  • npx playwright test runs every test in both projects.
  • Add --ui to watch the tests in a window with a timeline, --headed to see the browser, or --project=chromium to skip the mobile run while you iterate.
  • npx playwright show-report opens the HTML report from the last run.

Test sign-in without a real backend

The app has a /login page with an email field, a password field and a Sign in button. Submitting posts to /api/login, stores the token and sends the user to /dashboard, which greets them by name. A navigation guard sends a visitor without a token back to /login. The test should not depend on a real backend being up, so answer the request inside the browser:

e2e/login.spec.ts
import { expect, test } from '@playwright/test'

test('a member can sign in and reach the dashboard', async ({ page }) => {
  await page.route('**/api/login', (route) =>
    route.fulfill({ json: { name: 'Asha', token: 'test-token' } }),
  )

  await page.goto('/login')
  await page.getByLabel('Email').fill('asha@example.com')
  await page.getByLabel('Password').fill('correct horse battery')
  await page.getByRole('button', { name: 'Sign in' }).click()

  await expect(page).toHaveURL(/\/dashboard$/)
  await expect(page.getByRole('heading', { name: 'Welcome back, Asha' })).toBeVisible()
})

test('a visitor without a session is sent to the login page', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page).toHaveURL(/\/login$/)
  await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible()
})

page.route intercepts every request whose URL matches the pattern, and route.fulfill({ json }) answers it with a 200 status and a JSON body. The Vue code runs unchanged; only the network answer is staged.

getByLabel('Email') finds the input through its <label>. If the test cannot find the field, a screen reader cannot either, and the fix belongs in the component.

toHaveURL takes a regular expression and retries like the other assertions. That matters here, because the router navigates only after the response arrives. The second test checks the navigation guard from the other side in three lines.

Keep one real login, against a staging server with a test account, as a separate smoke test. Everything else can use page.route.

Find elements by role and let Playwright wait

Playwright's locator guide recommends locators based on what the user sees, with getByRole first, because it is the closest to how users and assistive technology perceive the page. In practice:

  • getByRole for buttons, links, headings and most other elements.
  • getByLabel for form fields.
  • getByPlaceholder, getByText and getByAltText where they fit better.
  • getByTestId only for an element with no visible name.

The guide does not recommend CSS and XPath selectors. They describe how the page is built rather than what the user sees, so a change to the markup can break them.

You do not write waits either. Before click(), Playwright waits until the element is visible, stable (not animating), enabled and not covered by another element. Before fill(), it waits until the field is visible, enabled and editable. The test that tries to wait by hand is the one that flakes:

e2e/login.spec.tsBreaks
await page.click('.btn-primary')
await page.waitForTimeout(1000)
const text = await page.textContent('h1')
expect(text).toBe('Welcome back, Asha')

Three things are wrong:

  • .btn-primary is a styling class, and the next restyle can rename it.
  • waitForTimeout(1000) is a guess: too short on a slow CI runner, too long everywhere else.
  • textContent reads the heading once, and a plain expect on a string does not retry, so the test fails whenever the heading arrives after that read.

The retrying version is shorter:

e2e/login.spec.tsWorks
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Welcome back, Asha' })).toBeVisible()

When a test fails, open the trace

With trace: 'on-first-retry', a test that fails in CI runs again with recording on, and the HTML report gets a trace icon next to it. The trace viewer shows every action on a timeline. For each step it has a DOM snapshot from before and after the action, which you can inspect with the browser's own DevTools, plus the network requests, the console output and the source line. Instead of guessing why a click did nothing, you look at the page as it was at that moment.

To open a trace:

  • Locally, where tests do not retry, force a recording: run npx playwright test --trace on, then npx playwright show-report.
  • From CI, download the playwright-report artifact, unzip it, and run npx playwright show-report followed by the folder's name.

The report in 1.57 also gained a Speedboard tab that lists tests by how long they took. Look there when the suite starts feeling slow.

Run the tests in GitHub Actions

The initialiser wrote .github/workflows/playwright.yml. Replace its contents with this shorter version:

.github/workflows/playwright.yml
name: e2e
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

npx playwright install --with-deps downloads the browsers and the system libraries they need on Ubuntu. The upload step runs even when tests fail, so the report and its traces are there when you need them. The CI guide recommends workers: 1 in CI for reproducible runs, which the config above already sets.

Instead of installing browsers on every run, you can use the official image. Set container: mcr.microsoft.com/playwright:v1.57.0-noble on the job and drop the npx playwright install --with-deps step. The image ships the browsers, their system libraries and, since 1.57, Node 24. Keep its tag equal to the @playwright/test version your project installs: when the two differ, Playwright cannot find its browsers and the run stops with "Executable doesn't exist at …".

Check accessibility with axe

Playwright can run axe, the accessibility rule engine, inside the page it just rendered. Install the integration package:

npm install -D @axe-core/playwright

Then add a test that scans a page and expects no violations:

e2e/a11y.spec.ts
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

test('the home page has no detectable accessibility violations', async ({ page }) => {
  await page.goto('/')
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze()
  expect(results.violations).toEqual([])
})

withTags limits the scan to the rules behind the WCAG 2.0 and 2.1 level A and AA success criteria. Without it, axe also runs best-practice rules that no WCAG criterion requires.

When the assertion fails, each violation in the diff carries an id such as color-contrast or label, a help URL and the HTML of the offending nodes. For a third-party widget you cannot fix, .exclude('#chat-widget') leaves it out of the scan, along with everything inside it, for every rule.

Run the same scan on the login page and, after the sign-in steps, on the dashboard. The accessibility testing guide is clear that automated checks find only some of the problems, and that many show up only when a person tests the page. This test is a floor, not a certificate.

Check it stuck3 questions from the End-to-end tests with Playwright quiz.
Practice now
Was this helpful?

Keep reading