Nuxt 3 End-to-End Testing with Playwright
Nuxt 3's server-side rendering, file-based routing, and auto-imports make it a powerful framework — and each of those features introduces testing considerations that plain Vue apps don't have. Playwright is the right tool for Nuxt 3 end-to-end testing: it handles SSR-rendered HTML, navigates routes, intercepts network requests, and runs reliably in CI.
This guide covers everything from initial setup to advanced patterns like auth state reuse, API mocking, and parallel execution.
Why Playwright for Nuxt 3
Nuxt 3 apps render HTML on the server before sending it to the browser. A unit test can verify component logic, but only an end-to-end test can confirm that:
- The server actually renders the correct HTML (not a loading spinner)
- Hydration completes without errors
- Navigation between routes works as expected
- Server routes (
/server/api/) return the right responses - Auth middleware redirects unauthenticated users
Playwright runs a real browser, receives the SSR HTML, waits for hydration, and interacts with the page exactly as a user would.
Installation and Setup
Install Playwright alongside @nuxt/test-utils:
npm install -D @playwright/test @nuxt/test-utils
npx playwright install chromiumCreate playwright.config.ts at the project root:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});The webServer block builds your Nuxt app and starts the preview server before tests run. In development, you can set reuseExistingServer: true to skip the build step.
Your First Nuxt 3 E2E Test
Create e2e/home.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Home page', () => {
test('renders SSR content', async ({ page }) => {
const response = await page.goto('/');
// Verify server returned 200 and the response is HTML
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/html');
// Check SSR-rendered title is in the initial HTML
const title = await page.title();
expect(title).toContain('My Nuxt App');
// Verify content rendered by the server (not client)
await expect(page.locator('h1')).toBeVisible();
});
test('navigates between routes without full reload', async ({ page }) => {
await page.goto('/');
// Click a NuxtLink and verify client-side navigation
const navigationPromise = page.waitForURL('/about');
await page.click('a[href="/about"]');
await navigationPromise;
// Page title should update without a server round-trip
await expect(page.locator('h1')).toContainText('About');
});
});Testing SSR vs Client Rendering
One unique Nuxt 3 testing challenge is distinguishing between SSR-rendered content and client-rendered content. Use Playwright's network interception to verify what the server actually sends:
test('critical content is server-rendered', async ({ page }) => {
// Intercept the initial HTML response
let initialHtml = '';
await page.route('/', async (route) => {
const response = await route.fetch();
initialHtml = await response.text();
await route.fulfill({ response });
});
await page.goto('/');
// Verify the product list is in the server-rendered HTML
// (not added by JavaScript after load)
expect(initialHtml).toContain('data-testid="product-list"');
// Also verify it's visible after hydration
await expect(page.locator('[data-testid="product-list"]')).toBeVisible();
});
test('dynamic data loads after hydration', async ({ page }) => {
await page.goto('/dashboard');
// Wait for client-side data fetch to complete
await page.waitForSelector('[data-testid="user-stats"]', { state: 'visible' });
// Verify the data rendered correctly
const statsText = await page.textContent('[data-testid="user-stats"]');
expect(statsText).toMatch(/\d+ tests/);
});Authentication State Reuse
The most common performance problem in Nuxt 3 E2E test suites is re-authenticating in every test. Use Playwright's storage state to save and reuse sessions:
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate as user', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'testpassword');
await page.click('[type="submit"]');
// Wait for Nuxt middleware to complete the redirect
await page.waitForURL('/dashboard');
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
// Save the browser state (cookies + localStorage)
await page.context().storageState({ path: authFile });
});In playwright.config.ts, add an auth setup dependency:
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'authenticated',
use: {
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],Now tests in the authenticated project start already logged in:
// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test';
// This test runs with the saved auth state — no login needed
test('dashboard shows user data', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');
});Testing Nuxt Middleware
Nuxt 3 route middleware runs on the server (for initial requests) and client (for navigation). Test both paths:
test.describe('Auth middleware', () => {
test('redirects unauthenticated users to login', async ({ page }) => {
// No auth state — fresh browser context
await page.goto('/dashboard');
// Middleware should redirect
await expect(page).toHaveURL('/login');
});
test('allows access to public pages without auth', async ({ page }) => {
await page.goto('/blog');
await expect(page).toHaveURL('/blog');
await expect(page.locator('h1')).toBeVisible();
});
});API Route Mocking
Nuxt 3 server routes (/server/api/) are real HTTP endpoints. You can mock them in tests using Playwright's page.route():
test('displays error state when API fails', async ({ page }) => {
// Intercept the Nuxt server API route
await page.route('**/api/products', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/products');
// Verify the error state renders
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
await expect(page.locator('[data-testid="error-message"]')).toContainText('failed to load');
});
test('handles paginated product list', async ({ page }) => {
let page_number = 1;
await page.route('**/api/products*', async (route) => {
const url = new URL(route.request().url());
const requestedPage = url.searchParams.get('page') || '1';
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: Array.from({ length: 10 }, (_, i) => ({
id: (parseInt(requestedPage) - 1) * 10 + i + 1,
name: `Product ${(parseInt(requestedPage) - 1) * 10 + i + 1}`,
})),
total: 50,
page: parseInt(requestedPage),
}),
});
});
await page.goto('/products');
await expect(page.locator('[data-testid="product-item"]')).toHaveCount(10);
await page.click('[data-testid="next-page"]');
await expect(page.locator('[data-testid="product-item"]')).toHaveCount(10);
});Testing useFetch and useAsyncData
Nuxt 3's composables trigger during SSR and re-run on the client. Test that data flows correctly end-to-end:
test('useFetch data renders correctly', async ({ page }) => {
// Mock the external API that useFetch calls
await page.route('https://api.external.com/data', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ value: 'mocked-data' }),
});
});
await page.goto('/data-page');
// Verify the data from useFetch rendered in the page
await expect(page.locator('[data-testid="data-value"]')).toContainText('mocked-data');
});CI/CD Integration
For GitHub Actions:
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build Nuxt app
run: npm run build
- name: Run E2E tests
run: npx playwright test
env:
BASE_URL: http://localhost:3000
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30Performance Considerations
Nuxt 3 E2E test suites can be slow because each test potentially involves SSR. Optimize with:
Parallel test execution: Playwright runs tests in parallel by default. Keep this enabled.
Selective browser testing: Run full suite against Chromium in CI; add Firefox and WebKit for critical paths only.
Component-level testing for speed: Use @nuxt/test-utils with mountSuspended for unit-level testing of Nuxt components — faster than launching a browser.
Stable selectors: Use data-testid attributes instead of CSS classes or text. Nuxt's CSS purging can change class names across builds.
Troubleshooting Common Issues
Hydration mismatch errors: If your app logs hydration warnings, tests may fail intermittently. Fix the root cause in your components; don't suppress the warnings.
Slow test startup: The webServer block builds the app on every CI run. Cache the .output directory to speed up subsequent runs.
Flaky navigation tests: Add explicit waitForURL() or waitForLoadState('networkidle') after clicks that trigger navigation. Don't rely on timeouts.
Auth state not persisting: Verify your Nuxt app sets cookies with SameSite=Lax or SameSite=None — SameSite=Strict can prevent cookies from being saved in Playwright's storage state.
Connecting to HelpMeTest
For continuous monitoring of your Nuxt 3 app in production, HelpMeTest runs Playwright-based tests on a schedule with no infrastructure to manage. Tests that pass in your CI suite run identically as health checks against your live application, alerting you when SSR breaks, authentication fails, or API routes return errors.
The same Playwright tests you write locally can monitor production 24/7 without maintaining separate browser infrastructure.
Summary
Nuxt 3 E2E testing with Playwright covers SSR correctness, hydration, routing, middleware, and API integration — areas that unit tests can't reach. Key practices:
- Use
webServerinplaywright.config.tsto build and start Nuxt before tests - Save auth state once with
storageStateand reuse it across all authenticated tests - Intercept
**/api/**routes to test error states and edge cases - Use
data-testidattributes for stable selectors - Run tests in parallel with Chromium in CI; expand browsers for critical paths
The result is a test suite that catches real Nuxt-specific issues — not just logic bugs, but SSR failures, middleware bypasses, and hydration errors that matter in production.