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.
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.@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.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.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.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.test.extend) for logged-in pages and seeded data; light page-object helpers for repeated flows; test.step for readable traces.--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.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.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.The Playwright fixture with hydration wait, a hydration-error guard, and role-based assertions:
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' } },
],
})
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' })
})
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:
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:
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 })
})
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.
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.
The current @nuxt/test-utils/playwright fixture API (goto options) and setup() options, and Playwright's current major and the status of toMatchAriaSnapshot.
Date.now() in a template to see the test fail.storageState in two tests; time the suite before and after."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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
Component tests
Vue Test Utils versus Testing Library, the Nuxt Vitest environment with mountSuspended and renderSuspended, mocking auto-imports, components and endpoints, testing async setup and Suspense, emitted events and slots, stores with createTestingPinia, and when Vitest browser mode is worth it.
Server and API tests
Testing Nitro routes at three levels — handler logic in isolation, routes through @nuxt/test-utils/e2e, and contracts against an OpenAPI document — with databases in tests (SQLite in memory, Testcontainers, transaction rollback), seeding and isolation, auth in tests, and validation and error-shape assertions.