Testing strategy

End-to-end with Playwright

Playwright's model — locators, auto-waiting, web-first assertions, fixtures, tracing — applied to Nuxt through @nuxt/test-utils/e2e and the Playwright fixture, with reusable auth state, network mocking, hydration checks, accessibility and visual assertions, sharding and a flake policy.

End-to-end tests are the only ones that prove the built application works: server rendering, hydration, routing, the API and the database together. They are also the slowest and the most tempting to write badly. Playwright's design (locators that wait, assertions that retry, traces on failure) removes most of the classic flakiness, and @nuxt/test-utils wires it to a Nuxt build.

Know

  • Locators, not selectors: getByRole('button', { name: 'Add to cart' }), getByLabel, getByText, getByTestId (last resort); locators are lazy and auto-wait for the element to be actionable, so explicit sleeps are a smell. Web-first assertions (await expect(locator).toBeVisible()) retry until they pass or time out.
  • Setup with @nuxt/test-utils/e2e: await setup({ browser: true, ... }) in a describe builds and starts the app once for the file (options: rootDir, configFile, server, build, port, host to test an already-running or deployed server, browserOptions, runner); then $fetch(path) and fetch(path) for server-rendered HTML and APIs, url(path), createPage(path) for a Playwright page, useTestContext(). Or use the Playwright fixture from @nuxt/test-utils/playwright with defineConfig and a goto(url, { waitUntil: 'hydration' }) helper that waits for Nuxt to hydrate.
  • Hydration as an assertion: listen for console messages and pageerror events and fail on hydration warnings or client errors; a page that works only after a re-render is a bug e2e tests should catch.
  • Authentication: log in once in a setup project, save storageState (cookies and localStorage) to a file, and reuse it in every test; for multiple roles, one state file each. Never type credentials in every test.
  • Data: each test creates what it needs through the API or a seed endpoint enabled only in test, or the database is reset between files; tests must not depend on each other or on a shared mutable fixture.
  • Network control: page.route() to mock third-party APIs (payments, maps) and to simulate failures and latency; leave your own API real, that is what you are testing. page.clock (fake time) for time-dependent UI.
  • Fixtures and page objects: custom fixtures (test.extend) for logged-in pages and seeded data; light page-object helpers for repeated flows; test.step for readable traces.
  • Debugging and evidence: --ui and --debug locally, trace: 'on-first-retry' in CI with the trace viewer, screenshots and videos on failure; --only-changed runs tests related to the diff.
  • Visual and accessibility: toHaveScreenshot() for visual regression with per-platform snapshots and maxDiffPixelRatio, toMatchAriaSnapshot() for accessibility-tree structure, @axe-core/playwright for WCAG rule checks on key pages.
  • Speed and flake policy: one browser (Chromium) in CI for most tests, the full matrix nightly; fullyParallel with --shard across machines and blob reports merged; retries: 2 only in CI, with a rule that a retried-pass is investigated; test.fixme quarantines with an owner; timeouts scaled to CI. Most flakes are missing awaits, tests depending on animation, or shared state.
  • Scope: end-to-end tests cover the critical journeys (sign-up, login, search, add to cart, checkout, the primary admin flow) and the SSR-specific risks (hydration, cookies, redirects, status codes). Everything else belongs in component tests.

How it works

The Playwright fixture with hydration wait, a hydration-error guard, and role-based assertions:

playwright.config.ts
import { fileURLToPath } from 'node:url'
import { defineConfig, devices } from '@playwright/test'
import type { ConfigOptions } from '@nuxt/test-utils/playwright'

export default defineConfig<ConfigOptions>({
  testDir: './test/e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? [['blob'], ['github']] : 'list',
  use: {
    nuxt: { rootDir: fileURLToPath(new URL('.', import.meta.url)) },
    trace: 'on-first-retry',
    ...devices['Desktop Chrome'],
  },
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    { name: 'chromium', dependencies: ['setup'], use: { storageState: 'test/e2e/.auth/user.json' } },
  ],
})
test/e2e/auth.setup.ts
import { expect, test as setup } from '@nuxt/test-utils/playwright'

setup('authenticate once', async ({ page, goto }) => {
  await goto('/login', { waitUntil: 'hydration' })
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!)
  await page.getByRole('button', { name: 'Sign in' }).click()
  await expect(page.getByRole('link', { name: 'Account' })).toBeVisible()
  await page.context().storageState({ path: 'test/e2e/.auth/user.json' })
})
test/e2e/checkout.spec.ts
import { expect, test } from '@nuxt/test-utils/playwright'

test.beforeEach(async ({ page }) => {
  // Hydration mismatches and client errors fail the test, whatever the assertions say.
  page.on('console', (msg) => {
    if (msg.type() === 'warning' && /hydration/i.test(msg.text())) throw new Error(`Hydration warning: ${msg.text()}`)
  })
  page.on('pageerror', (err) => { throw err })
})

test('adds a product and reaches payment', async ({ page, goto }) => {
  await goto('/products/kettle', { waitUntil: 'hydration' })
  await test.step('add to cart', async () => {
    await page.getByRole('button', { name: 'Add to cart' }).click()
    await expect(page.getByRole('status')).toHaveText(/added/i)
  })
  await test.step('checkout', async () => {
    await page.getByRole('link', { name: /cart/i }).click()
    await page.getByRole('button', { name: 'Checkout' }).click()
    await expect(page).toHaveURL(/\/checkout/)
    await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible()
  })
})

test('mocks the payment provider and shows failure state', async ({ page, goto }) => {
  await page.route('https://api.payments.example.com/**', route => route.fulfill({ status: 502, body: 'down' }))
  await goto('/checkout', { waitUntil: 'hydration' })
  await page.getByRole('button', { name: 'Pay' }).click()
  await expect(page.getByRole('alert')).toContainText(/payment failed/i)
})

Server-side checks with @nuxt/test-utils/e2e, where a browser is unnecessary:

test/e2e/ssr.spec.ts
import { describe, expect, it } from 'vitest'
import { $fetch, fetch, setup } from '@nuxt/test-utils/e2e'

describe('ssr', async () => {
  await setup({ server: true })

  it('renders the product in the server HTML with the right status', async () => {
    const html = await $fetch('/products/kettle')
    expect(html).toContain('<h1>Kettle</h1>')
    const missing = await fetch('/products/does-not-exist')
    expect(missing.status).toBe(404)
  })

  it('redirects anonymous users from the account area', async () => {
    const res = await fetch('/account', { redirect: 'manual' })
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toContain('/login')
  })
})

Accessibility and visual assertions on the key pages:

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

test('home passes WCAG 2.2 AA rules and matches the visual baseline', async ({ page, goto }) => {
  await goto('/', { waitUntil: 'hydration' })
  const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze()
  expect(axe.violations).toEqual([])
  await expect(page.getByRole('main')).toMatchAriaSnapshot(`
    - heading "Senior frontend · Nuxt · fullstack prep" [level=1]
    - link "Train"
  `)
  await expect(page).toHaveScreenshot('home.png', { maxDiffPixelRatio: 0.01, fullPage: true })
})
Gotcha· waitForTimeout

await page.waitForTimeout(1000) is a guess dressed as a fix; it is slow when the app is fast and flaky when it is slow. Wait for a condition: a locator to be visible, a URL, a response, or Nuxt's hydration via the fixture's goto.

Gotcha· Tests that log in

Typing credentials in every test makes the suite slow and hammers the auth service; a single setup project that saves storageState and per-role state files fixes both, and the login flow still gets its own dedicated test.

Verify before the interview:

The current @nuxt/test-utils/playwright fixture API (goto options) and setup() options, and Playwright's current major and the status of toMatchAriaSnapshot.

docs ↗

Exercise

Exercise
  • Set up the Playwright fixture on a Nuxt app, add the hydration-warning guard, and introduce a Date.now() in a template to see the test fail.
  • Create the auth setup project and reuse storageState in two tests; time the suite before and after.
  • Add an axe check and an aria snapshot to the home page, then break a heading level and read both failures.

Be able to say

Be able to say· How do you write end-to-end tests for a Nuxt app so they stay reliable?

"With Playwright against the built app, through @nuxt/test-utils: its fixture's goto waits for hydration, so tests do not race the client. Locators by role and label auto-wait, assertions retry, and there is never a hard sleep. Authentication happens once in a setup project whose storage state every test reuses. Each test creates its own data through the API, so order does not matter and files run fully in parallel and sharded in CI. Third-party services are mocked with page.route while my own API stays real, because that integration is the point. Hydration warnings and page errors fail the test outright, and axe plus aria snapshots run on the key pages. Retries exist only in CI and a retried pass is investigated, because a flaky test is a bug in the test or the product."

Be able to say· What belongs in end-to-end tests versus component tests?

"End-to-end covers the few journeys whose failure would be an incident, sign-up, login, search, add to cart, checkout, the main admin flow, and the risks only a real build exposes: server rendering, hydration, cookies and redirects, status codes, the API and database together. Component tests cover everything else: states, validation, edge cases, accessibility of individual widgets, keyboard behaviour, because they run in milliseconds and pinpoint the failure. If I find myself writing an end-to-end test for a form's validation messages, I move it down; if a component test needs the real API, I move it up."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.