End-to-End Testing
End-to-end tests run your actual application in a real browser and interact with it the way a user would. They catch problems that unit and component tests miss — broken API integrations, misconfigured routes, CSS that hides a button, a form submission that does not redirect properly. The tradeoff is speed: an E2E test that logs in, navigates to a dashboard, and creates a resource takes seconds instead of milliseconds.
Playwright is the stronger choice for new projects in 2026. It runs tests across Chromium, Firefox, and WebKit, has built-in auto-waiting, and its API is cleaner than Cypress's. Cypress still has a devoted following and a good interactive runner, but Playwright's multi-browser support and parallelism make it the more practical default.
Setting Up Playwright with Nuxt
npx nuxi module add @nuxt/test-utils
npm install -D @playwright/test
npx playwright install
Create a Playwright config:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npx nuxi preview',
port: 3000,
reuseExistingServer: !process.env.CI,
},
})
The webServer block is key — Playwright starts your Nuxt app before tests and stops it after. In CI, it builds and serves from scratch. Locally, it reuses a running dev server if one exists.
Writing Your First E2E Test
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
test('homepage loads and shows the hero section', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('link', { name: 'Get Started' })).toBeVisible()
})
test('navigation works', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Pricing' }).click()
await expect(page).toHaveURL('/pricing')
await expect(page.getByRole('heading', { name: 'Pricing' })).toBeVisible()
})
Playwright auto-waits for elements to be visible before interacting with them. No explicit waitFor calls needed in most cases.
Testing Form Submissions
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'
test('contact form submits successfully', async ({ page }) => {
await page.goto('/contact')
await page.getByLabel('Name').fill('Alice Johnson')
await page.getByLabel('Email').fill('alice@example.com')
await page.getByLabel('Message').fill('I have a question about your API pricing.')
await page.getByRole('button', { name: 'Send Message' }).click()
// Wait for the success message
await expect(page.getByText('Message sent')).toBeVisible()
})
test('contact form shows validation errors', async ({ page }) => {
await page.goto('/contact')
// Submit without filling anything
await page.getByRole('button', { name: 'Send Message' }).click()
await expect(page.getByText('Name is required')).toBeVisible()
await expect(page.getByText('Email is required')).toBeVisible()
})
Testing Authentication Flows
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test('user can sign up and reach the dashboard', async ({ page }) => {
await page.goto('/signup')
await page.getByLabel('Email').fill('newuser@example.com')
await page.getByLabel('Password').fill('secureP@ss123')
await page.getByLabel('Confirm Password').fill('secureP@ss123')
await page.getByRole('button', { name: 'Create Account' }).click()
// Should redirect to dashboard after signup
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('Welcome')).toBeVisible()
})
test('invalid credentials show an error', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('nobody@example.com')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page.getByText('Invalid email or password')).toBeVisible()
await expect(page).toHaveURL('/login')
})
For tests that require an authenticated user, avoid logging in through the UI every time. Use Playwright's storageState to persist authentication:
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('testpass123')
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page).toHaveURL('/dashboard')
// Save the authenticated state
await page.context().storageState({ path: './e2e/.auth/user.json' })
})
// playwright.config.ts — add a setup project
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: './e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
})
Now all tests in the chromium project run as an authenticated user without repeating the login flow.
API Mocking with MSW
Mock Service Worker (MSW) intercepts network requests at the service worker level. It works in both browser and Node.js environments, so the same mocks work for component tests and E2E tests.
npm install -D msw
npx msw init public/ --save
Define your handlers:
// e2e/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/products', () => {
return HttpResponse.json([
{ id: '1', name: 'Widget Pro', price: 2999, inStock: true },
{ id: '2', name: 'Gadget Plus', price: 4999, inStock: false },
])
}),
http.post('/api/orders', async ({ request }) => {
const body = await request.json()
return HttpResponse.json(
{ id: 'order-123', status: 'confirmed', items: body.items },
{ status: 201 },
)
}),
http.get('/api/users/me', () => {
return HttpResponse.json({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
})
}),
]
For Playwright, MSW works differently than in Vitest since you are running a real browser. Use Playwright's built-in route interception for simpler cases:
// e2e/products.spec.ts
import { test, expect } from '@playwright/test'
test('displays products from the API', async ({ page }) => {
// Intercept the API call at the browser level
await page.route('/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: '1', name: 'Widget Pro', price: 2999 },
{ id: '2', name: 'Gadget Plus', price: 4999 },
]),
})
})
await page.goto('/products')
await expect(page.getByText('Widget Pro')).toBeVisible()
await expect(page.getByText('Gadget Plus')).toBeVisible()
})
test('handles API errors gracefully', async ({ page }) => {
await page.route('/api/products', async (route) => {
await route.fulfill({ status: 500 })
})
await page.goto('/products')
await expect(page.getByText('Failed to load products')).toBeVisible()
})
CI Integration
GitHub Actions
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx nuxi build
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=chromium
env:
CI: true
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Install only the browsers you actually test. playwright install --with-deps chromium is much faster than installing all three browsers. Most teams only run Chromium in CI and test Firefox/WebKit locally or on a less-frequent schedule.
Running E2E Tests Efficiently
E2E tests are slow. Structure your CI to run them smartly:
// playwright.config.ts
export default defineConfig({
// Retry failed tests in CI to handle flakiness
retries: process.env.CI ? 2 : 0,
// Run tests in parallel locally, serial in CI (more predictable)
workers: process.env.CI ? 1 : undefined,
// Capture trace on first retry for debugging
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
})
Visual Regression Testing
Visual regression tests screenshot pages and compare them against baseline images. Playwright has built-in support:
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test('pricing page visual regression', async ({ page }) => {
await page.goto('/pricing')
await expect(page).toHaveScreenshot('pricing-page.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
})
})
test('login form visual regression', async ({ page }) => {
await page.goto('/login')
const form = page.getByTestId('login-form')
await expect(form).toHaveScreenshot('login-form.png')
})
Run npx playwright test --update-snapshots to generate or update baseline images. Store them in git.
The maxDiffPixelRatio threshold is important — set it too low and you get false failures from anti-aliasing differences across environments. 0.01 (1% pixel difference) is a reasonable starting point.
Visual regression tests are most valuable for design system components (buttons, cards, modals) and key landing pages. Do not screenshot every page — the maintenance overhead is not worth it for rapidly changing features.
Common Pitfalls
Running E2E tests on every commit. E2E tests are slow. Run unit and component tests on every push; run E2E tests on pull requests and before merges. Use paths-ignore in GitHub Actions to skip E2E runs when only docs changed.
Hard-coding wait times. page.waitForTimeout(3000) is a test smell. Use Playwright's auto-waiting or explicit conditions like await expect(element).toBeVisible(). Arbitrary timeouts make tests slow and flaky.
Not cleaning test data. If your tests create users or orders in a real database, they pollute the environment for the next run. Either use API mocking, a test database that resets between runs, or create data with unique identifiers and clean up in afterEach.
Testing too much in E2E. Every edge case of your form validation does not need an E2E test. Test the happy path and one or two error paths in E2E. Cover the rest with component tests that run in milliseconds.
Ignoring flaky tests. A test that fails intermittently is worse than no test — it erodes trust in the test suite. Fix flaky tests immediately or quarantine them. The retries option is a bandaid, not a solution.
Key Takeaways
- Playwright is the best default for E2E testing in 2026 — multi-browser, fast, great API.
- Use
webServerin Playwright config to automatically start and stop your Nuxt app. - Authenticate once with
storageStateinstead of logging in through the UI for every test. - Use
page.route()for API mocking in E2E tests — it is simpler than setting up MSW in the browser. - Run E2E tests on pull requests, not every commit. Keep them focused on critical user journeys.
- Visual regression tests are valuable for design system components but costly to maintain for frequently changing pages.