Nuxt 3 End-to-End Testing with Playwright

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 chromium

Create 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: 30

Performance 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=NoneSameSite=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 webServer in playwright.config.ts to build and start Nuxt before tests
  • Save auth state once with storageState and reuse it across all authenticated tests
  • Intercept **/api/** routes to test error states and edge cases
  • Use data-testid attributes 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.

Read more