Skip to content

Latest commit

 

History

History
1162 lines (898 loc) · 37.4 KB

File metadata and controls

1162 lines (898 loc) · 37.4 KB

Testing Vue Apps with Playwright

When to use: Testing Vue 3 applications, including composition API components, Pinia stores, Vue Router navigation, Nuxt.js apps, Teleport portals, and transitions. Covers E2E testing and experimental component testing with @playwright/experimental-ct-vue. Prerequisites: core/configuration.md, core/locators.md

Quick Reference

# Install Playwright in a Vue project
npm init playwright@latest

# Install component testing (experimental)
npm install -D @playwright/experimental-ct-vue

# Run E2E tests
npx playwright test

# Run component tests
npx playwright test -c playwright-ct.config.ts

# Debug a test with headed browser and inspector
npx playwright test tests/home.spec.ts --headed --debug

Setup

Playwright Config for Vue (Vite)

TypeScript

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  testMatch: '**/*.spec.ts',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  use: {
    baseURL: 'http://localhost:5173',
    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: process.env.CI
      ? 'npm run build && npx vite preview --port 5173'
      : 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

JavaScript

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests/e2e',
  testMatch: '**/*.spec.js',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? '50%' : undefined,

  use: {
    baseURL: 'http://localhost:5173',
    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: process.env.CI
      ? 'npm run build && npx vite preview --port 5173'
      : 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Component Testing Config

TypeScript

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-vue';

export default defineConfig({
  testDir: './tests/components',
  testMatch: '**/*.ct.ts',

  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

JavaScript

// playwright-ct.config.js
const { defineConfig, devices } = require('@playwright/experimental-ct-vue');

module.exports = defineConfig({
  testDir: './tests/components',
  testMatch: '**/*.ct.js',

  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Nuxt.js Setup

Nuxt requires a build step before testing and uses port 3000 by default.

TypeScript

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  testMatch: '**/*.spec.ts',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],

  webServer: {
    command: process.env.CI
      ? 'npx nuxi build && npx nuxi preview'
      : 'npx nuxi dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',
    },
  },
});

JavaScript

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests/e2e',
  testMatch: '**/*.spec.js',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],

  webServer: {
    command: process.env.CI
      ? 'npx nuxi build && npx nuxi preview'
      : 'npx nuxi dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    env: {
      NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',
    },
  },
});

Patterns

Component Testing with @playwright/experimental-ct-vue

Use when: Testing complex interactive Vue components in isolation -- data tables, form components, custom select dropdowns, rich editors. The component needs a real browser but not a full application. Avoid when: The component depends heavily on Pinia stores, Vue Router, or backend data. Use E2E tests instead, or provide the dependencies in your component test setup.

TypeScript

// tests/components/Counter.ct.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import Counter from '../../src/components/Counter.vue';

test('renders with initial count and increments', async ({ mount }) => {
  const component = await mount(Counter, {
    props: {
      initialCount: 0,
    },
  });

  await expect(component.getByText('Count: 0')).toBeVisible();
  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component.getByText('Count: 1')).toBeVisible();
});

test('emits update event when count changes', async ({ mount }) => {
  const emittedValues: number[] = [];
  const component = await mount(Counter, {
    props: {
      initialCount: 5,
    },
    on: {
      update: (value: number) => emittedValues.push(value),
    },
  });

  await component.getByRole('button', { name: 'Increment' }).click();
  await component.getByRole('button', { name: 'Increment' }).click();

  expect(emittedValues).toEqual([6, 7]);
});

test('respects max prop', async ({ mount }) => {
  const component = await mount(Counter, {
    props: {
      initialCount: 9,
      max: 10,
    },
  });

  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component.getByText('Count: 10')).toBeVisible();

  // At max -- button should be disabled
  await expect(component.getByRole('button', { name: 'Increment' })).toBeDisabled();
});

test('renders with slot content', async ({ mount }) => {
  const component = await mount(Counter, {
    props: { initialCount: 0 },
    slots: {
      default: '<span class="custom-label">Items in cart</span>',
    },
  });

  await expect(component.getByText('Items in cart')).toBeVisible();
});

JavaScript

// tests/components/Counter.ct.js
const { test, expect } = require('@playwright/experimental-ct-vue');
const Counter = require('../../src/components/Counter.vue');

test('renders with initial count and increments', async ({ mount }) => {
  const component = await mount(Counter, {
    props: {
      initialCount: 0,
    },
  });

  await expect(component.getByText('Count: 0')).toBeVisible();
  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component.getByText('Count: 1')).toBeVisible();
});

test('emits update event when count changes', async ({ mount }) => {
  const emittedValues = [];
  const component = await mount(Counter, {
    props: {
      initialCount: 5,
    },
    on: {
      update: (value) => emittedValues.push(value),
    },
  });

  await component.getByRole('button', { name: 'Increment' }).click();
  await component.getByRole('button', { name: 'Increment' }).click();

  expect(emittedValues).toEqual([6, 7]);
});

test('renders with slot content', async ({ mount }) => {
  const component = await mount(Counter, {
    props: { initialCount: 0 },
    slots: {
      default: '<span class="custom-label">Items in cart</span>',
    },
  });

  await expect(component.getByText('Items in cart')).toBeVisible();
});

Testing Pinia Stores Through the UI

Use when: Verifying that Pinia stores produce the correct UI behavior. Playwright tests the rendered output, not the store directly. If the UI is correct, the store is correct. Avoid when: Testing pure store logic (getters, actions with no UI side effect) -- use unit tests with Vitest for that.

TypeScript

import { test, expect } from '@playwright/test';

test.describe('Pinia cart store (tested through UI)', () => {
  test('adding items updates the cart across the app', async ({ page }) => {
    await page.goto('/products');

    const cartBadge = page.getByTestId('cart-count');

    // Initially empty
    await expect(cartBadge).toHaveText('0');

    // Add first item -- Pinia store action is triggered
    await page.getByRole('listitem')
      .filter({ hasText: 'Vue T-Shirt' })
      .getByRole('button', { name: 'Add to cart' })
      .click();
    await expect(cartBadge).toHaveText('1');

    // Add second item
    await page.getByRole('listitem')
      .filter({ hasText: 'Pinia Sticker Pack' })
      .getByRole('button', { name: 'Add to cart' })
      .click();
    await expect(cartBadge).toHaveText('2');

    // Navigate to cart page -- store state persists
    await page.getByRole('link', { name: 'Cart' }).click();
    await page.waitForURL('/cart');

    await expect(page.getByText('Vue T-Shirt')).toBeVisible();
    await expect(page.getByText('Pinia Sticker Pack')).toBeVisible();
    await expect(page.getByText('Total: $29.98')).toBeVisible();
  });

  test('removing items from cart updates total', async ({ page }) => {
    // Navigate to a pre-populated cart (seed via API or navigate through adding items)
    await page.goto('/products');
    await page.getByRole('listitem')
      .filter({ hasText: 'Vue T-Shirt' })
      .getByRole('button', { name: 'Add to cart' })
      .click();

    await page.getByRole('link', { name: 'Cart' }).click();
    await page.waitForURL('/cart');

    await page.getByRole('button', { name: 'Remove Vue T-Shirt' }).click();

    await expect(page.getByText('Your cart is empty')).toBeVisible();
    await expect(page.getByTestId('cart-count')).toHaveText('0');
  });

  test('store persists after page reload when using pinia-plugin-persistedstate', async ({ page }) => {
    await page.goto('/products');

    await page.getByRole('listitem')
      .filter({ hasText: 'Vue T-Shirt' })
      .getByRole('button', { name: 'Add to cart' })
      .click();

    // Reload the page -- persisted store should restore state
    await page.reload();

    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });
});

test.describe('Pinia auth store (tested through UI)', () => {
  test('login updates auth state across all components', async ({ page }) => {
    await page.goto('/');

    await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();

    await page.getByRole('link', { name: 'Sign in' }).click();
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await page.waitForURL('/dashboard');

    // Header updates (uses auth store)
    await expect(page.getByText('user@example.com')).toBeVisible();
    // Navigation updates (uses auth store for conditional routes)
    await expect(page.getByRole('link', { name: 'Admin' })).toBeVisible();
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('Pinia cart store (tested through UI)', () => {
  test('adding items updates the cart across the app', async ({ page }) => {
    await page.goto('/products');

    const cartBadge = page.getByTestId('cart-count');
    await expect(cartBadge).toHaveText('0');

    await page.getByRole('listitem')
      .filter({ hasText: 'Vue T-Shirt' })
      .getByRole('button', { name: 'Add to cart' })
      .click();
    await expect(cartBadge).toHaveText('1');

    await page.getByRole('listitem')
      .filter({ hasText: 'Pinia Sticker Pack' })
      .getByRole('button', { name: 'Add to cart' })
      .click();
    await expect(cartBadge).toHaveText('2');

    await page.getByRole('link', { name: 'Cart' }).click();
    await page.waitForURL('/cart');

    await expect(page.getByText('Vue T-Shirt')).toBeVisible();
    await expect(page.getByText('Pinia Sticker Pack')).toBeVisible();
  });

  test('store persists after page reload when using pinia-plugin-persistedstate', async ({ page }) => {
    await page.goto('/products');

    await page.getByRole('listitem')
      .filter({ hasText: 'Vue T-Shirt' })
      .getByRole('button', { name: 'Add to cart' })
      .click();

    await page.reload();
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });
});

Testing Vue Router Navigation

Use when: Testing client-side routing with Vue Router. Verify route transitions, navigation guards, URL parameters, and browser history behavior. Avoid when: Testing Nuxt.js file-based routing -- the patterns are similar but Nuxt has additional server-side concerns.

TypeScript

import { test, expect } from '@playwright/test';

test.describe('Vue Router navigation', () => {
  test('client-side navigation does not cause full page reload', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      (window as any).__testMarker = 'no-reload';
    });

    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('/products');

    const marker = await page.evaluate(() => (window as any).__testMarker);
    expect(marker).toBe('no-reload');
  });

  test('dynamic route params render correct content', async ({ page }) => {
    await page.goto('/products/42');

    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
    await expect(page.getByText('Product #42')).toBeVisible();
  });

  test('navigation guard redirects unauthenticated users', async ({ page }) => {
    // Visit a route guarded by router.beforeEach
    await page.goto('/admin/users');

    // Guard should redirect to login
    await expect(page).toHaveURL(/\/login/);
    await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
  });

  test('named routes and nested views render correctly', async ({ page }) => {
    await page.goto('/settings');

    // Parent layout
    await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
    await expect(page.getByRole('navigation', { name: 'Settings' })).toBeVisible();

    // Default child route
    await expect(page.getByRole('heading', { name: 'General', level: 2 })).toBeVisible();

    // Navigate to sibling child route
    await page.getByRole('link', { name: 'Notifications' }).click();
    await page.waitForURL('/settings/notifications');

    // Parent layout persists, child content changes
    await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Notifications', level: 2 })).toBeVisible();
  });

  test('browser back/forward works with Vue Router', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('/products');
    await page.getByRole('link', { name: 'About' }).click();
    await page.waitForURL('/about');

    await page.goBack();
    await expect(page).toHaveURL(/\/products/);

    await page.goBack();
    await expect(page).toHaveURL(/\/$/);

    await page.goForward();
    await expect(page).toHaveURL(/\/products/);
  });

  test('query parameters update reactive computed properties', async ({ page }) => {
    await page.goto('/products?sort=price-asc&category=electronics');

    await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();

    // Change sort via UI (updates route query params)
    await page.getByRole('combobox', { name: 'Sort by' }).selectOption('name-asc');
    await expect(page).toHaveURL(/sort=name-asc/);
  });

  test('404 catch-all route displays not found page', async ({ page }) => {
    await page.goto('/this-page-does-not-exist');

    await expect(page.getByRole('heading', { name: 'Page Not Found' })).toBeVisible();
    await expect(page.getByRole('link', { name: 'Go home' })).toBeVisible();
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('Vue Router navigation', () => {
  test('client-side navigation does not cause full page reload', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      window.__testMarker = 'no-reload';
    });

    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('/products');

    const marker = await page.evaluate(() => window.__testMarker);
    expect(marker).toBe('no-reload');
  });

  test('dynamic route params render correct content', async ({ page }) => {
    await page.goto('/products/42');

    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
    await expect(page.getByText('Product #42')).toBeVisible();
  });

  test('navigation guard redirects unauthenticated users', async ({ page }) => {
    await page.goto('/admin/users');

    await expect(page).toHaveURL(/\/login/);
    await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
  });

  test('browser back/forward works with Vue Router', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('/products');
    await page.getByRole('link', { name: 'About' }).click();
    await page.waitForURL('/about');

    await page.goBack();
    await expect(page).toHaveURL(/\/products/);

    await page.goBack();
    await expect(page).toHaveURL(/\/$/);
  });
});

Testing Teleport (Portals)

Use when: Testing components rendered via Vue's <Teleport> -- modals, notifications, overlay menus. Teleport moves DOM elements to a different location (often body), but Playwright sees the entire document. Avoid when: The component is not teleported -- it renders inline in its parent.

TypeScript

import { test, expect } from '@playwright/test';

test.describe('Teleport components', () => {
  test('modal teleported to body is visible and interactive', async ({ page }) => {
    await page.goto('/products');

    await page.getByRole('button', { name: 'Delete' }).first().click();

    // Modal is teleported to <body> with <Teleport to="body">
    // Playwright sees it like any other element
    const modal = page.getByRole('dialog', { name: 'Confirm deletion' });
    await expect(modal).toBeVisible();

    await modal.getByRole('button', { name: 'Cancel' }).click();
    await expect(modal).toBeHidden();
  });

  test('teleported notification appears above all content', async ({ page }) => {
    await page.goto('/settings');

    await page.getByRole('button', { name: 'Save' }).click();

    // Notification teleported to #notifications container
    const notification = page.getByRole('alert');
    await expect(notification).toBeVisible();
    await expect(notification).toContainText('Settings saved');

    // Auto-dismiss
    await expect(notification).toBeHidden({ timeout: 10_000 });
  });

  test('teleported dropdown closes on outside click', async ({ page }) => {
    await page.goto('/dashboard');

    await page.getByRole('button', { name: 'User menu' }).click();

    const menu = page.getByRole('menu');
    await expect(menu).toBeVisible();

    // Click outside the menu
    await page.locator('body').click({ position: { x: 10, y: 10 } });
    await expect(menu).toBeHidden();
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('Teleport components', () => {
  test('modal teleported to body is visible and interactive', async ({ page }) => {
    await page.goto('/products');

    await page.getByRole('button', { name: 'Delete' }).first().click();

    const modal = page.getByRole('dialog', { name: 'Confirm deletion' });
    await expect(modal).toBeVisible();

    await modal.getByRole('button', { name: 'Cancel' }).click();
    await expect(modal).toBeHidden();
  });

  test('teleported notification appears above all content', async ({ page }) => {
    await page.goto('/settings');

    await page.getByRole('button', { name: 'Save' }).click();

    const notification = page.getByRole('alert');
    await expect(notification).toBeVisible();
    await expect(notification).toContainText('Settings saved');

    await expect(notification).toBeHidden({ timeout: 10_000 });
  });

  test('teleported dropdown closes on outside click', async ({ page }) => {
    await page.goto('/dashboard');

    await page.getByRole('button', { name: 'User menu' }).click();

    const menu = page.getByRole('menu');
    await expect(menu).toBeVisible();

    await page.locator('body').click({ position: { x: 10, y: 10 } });
    await expect(menu).toBeHidden();
  });
});

Testing Transitions and Animations

Use when: Verifying that Vue <Transition> and <TransitionGroup> components work correctly -- elements appear, disappear, and reorder with expected behavior. Focus on the end state, not the animation itself. Avoid when: Testing the exact CSS animation keyframes -- visual regression testing is better for pixel-level validation.

TypeScript

import { test, expect } from '@playwright/test';

test.describe('transitions', () => {
  test('list items animate in when added', async ({ page }) => {
    await page.goto('/todo');

    await page.getByRole('textbox', { name: 'New task' }).fill('Buy groceries');
    await page.getByRole('button', { name: 'Add' }).click();

    // The item should be visible after the enter transition completes
    // Playwright auto-waits for visibility, handling the transition automatically
    await expect(page.getByText('Buy groceries')).toBeVisible();
  });

  test('deleted items animate out and are removed from DOM', async ({ page }) => {
    await page.goto('/todo');

    // Add an item first
    await page.getByRole('textbox', { name: 'New task' }).fill('Temporary task');
    await page.getByRole('button', { name: 'Add' }).click();
    await expect(page.getByText('Temporary task')).toBeVisible();

    // Delete it
    await page.getByRole('listitem')
      .filter({ hasText: 'Temporary task' })
      .getByRole('button', { name: 'Delete' })
      .click();

    // After the leave transition, the element should be gone
    await expect(page.getByText('Temporary task')).toBeHidden();
  });

  test('page transition completes between routes', async ({ page }) => {
    await page.goto('/');

    await page.getByRole('link', { name: 'About' }).click();
    await page.waitForURL('/about');

    // After the route transition animation, content should be visible
    await expect(page.getByRole('heading', { name: 'About' })).toBeVisible();
  });

  test('disable animations for faster, more reliable tests', async ({ page }) => {
    // Disable all CSS animations and transitions for deterministic testing
    await page.addStyleTag({
      content: `
        *, *::before, *::after {
          animation-duration: 0s !important;
          animation-delay: 0s !important;
          transition-duration: 0s !important;
          transition-delay: 0s !important;
        }
      `,
    });

    await page.goto('/todo');

    await page.getByRole('textbox', { name: 'New task' }).fill('Instant task');
    await page.getByRole('button', { name: 'Add' }).click();

    // With animations disabled, elements appear/disappear instantly
    await expect(page.getByText('Instant task')).toBeVisible();
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('transitions', () => {
  test('list items animate in when added', async ({ page }) => {
    await page.goto('/todo');

    await page.getByRole('textbox', { name: 'New task' }).fill('Buy groceries');
    await page.getByRole('button', { name: 'Add' }).click();

    await expect(page.getByText('Buy groceries')).toBeVisible();
  });

  test('deleted items animate out and are removed from DOM', async ({ page }) => {
    await page.goto('/todo');

    await page.getByRole('textbox', { name: 'New task' }).fill('Temporary task');
    await page.getByRole('button', { name: 'Add' }).click();
    await expect(page.getByText('Temporary task')).toBeVisible();

    await page.getByRole('listitem')
      .filter({ hasText: 'Temporary task' })
      .getByRole('button', { name: 'Delete' })
      .click();

    await expect(page.getByText('Temporary task')).toBeHidden();
  });

  test('disable animations for faster, more reliable tests', async ({ page }) => {
    await page.addStyleTag({
      content: `
        *, *::before, *::after {
          animation-duration: 0s !important;
          animation-delay: 0s !important;
          transition-duration: 0s !important;
          transition-delay: 0s !important;
        }
      `,
    });

    await page.goto('/todo');

    await page.getByRole('textbox', { name: 'New task' }).fill('Instant task');
    await page.getByRole('button', { name: 'Add' }).click();

    await expect(page.getByText('Instant task')).toBeVisible();
  });
});

Testing Composition API Components

Use when: Testing components built with the Vue 3 Composition API (<script setup>, setup() function). From Playwright's perspective, Composition API and Options API components are identical -- you test the rendered output. Avoid when: You want to test composable functions in isolation -- use Vitest for that.

TypeScript

import { test, expect } from '@playwright/test';

test.describe('composition API patterns', () => {
  test('computed properties update the UI reactively', async ({ page }) => {
    await page.goto('/calculator');

    await page.getByLabel('Price').fill('100');
    await page.getByLabel('Quantity').fill('3');

    // Computed total should update reactively
    await expect(page.getByTestId('total')).toHaveText('$300.00');

    // Apply discount
    await page.getByLabel('Discount (%)').fill('10');
    await expect(page.getByTestId('total')).toHaveText('$270.00');
  });

  test('watcher triggers side effect on value change', async ({ page }) => {
    await page.goto('/settings');

    // Changing the language triggers a watcher that reloads translations
    await page.getByRole('combobox', { name: 'Language' }).selectOption('fr');

    // Watcher fetches French translations -- UI updates
    await expect(page.getByRole('heading', { name: 'Parametres' })).toBeVisible();
  });

  test('composable hook (useSearch) provides debounced search', async ({ page }) => {
    await page.goto('/products');

    const searchInput = page.getByRole('textbox', { name: 'Search products' });
    await searchInput.pressSequentially('vue stickers', { delay: 50 });

    // useSearch composable debounces and fetches results
    await expect(page.getByRole('listitem')).toHaveCount(3);
    await expect(page.getByText('Vue Sticker Pack')).toBeVisible();
  });

  test('provide/inject context works across component tree', async ({ page }) => {
    await page.goto('/dashboard');

    // Theme is provided at the app root and injected in child components
    await page.getByRole('switch', { name: 'Dark mode' }).click();

    // All components using inject('theme') should update
    await expect(page.locator('body')).toHaveClass(/dark/);
    await expect(page.getByRole('navigation')).toHaveCSS('background-color', /rgb\(30/);
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('composition API patterns', () => {
  test('computed properties update the UI reactively', async ({ page }) => {
    await page.goto('/calculator');

    await page.getByLabel('Price').fill('100');
    await page.getByLabel('Quantity').fill('3');

    await expect(page.getByTestId('total')).toHaveText('$300.00');

    await page.getByLabel('Discount (%)').fill('10');
    await expect(page.getByTestId('total')).toHaveText('$270.00');
  });

  test('watcher triggers side effect on value change', async ({ page }) => {
    await page.goto('/settings');

    await page.getByRole('combobox', { name: 'Language' }).selectOption('fr');
    await expect(page.getByRole('heading', { name: 'Parametres' })).toBeVisible();
  });

  test('composable hook (useSearch) provides debounced search', async ({ page }) => {
    await page.goto('/products');

    const searchInput = page.getByRole('textbox', { name: 'Search products' });
    await searchInput.pressSequentially('vue stickers', { delay: 50 });

    await expect(page.getByRole('listitem')).toHaveCount(3);
    await expect(page.getByText('Vue Sticker Pack')).toBeVisible();
  });
});

Testing Nuxt.js Specific Patterns

Use when: Testing Nuxt 3 applications with server-side rendering, auto-imports, server routes (/server/api/), and middleware. Avoid when: Testing a plain Vue SPA without Nuxt.

TypeScript

import { test, expect } from '@playwright/test';

test.describe('Nuxt-specific patterns', () => {
  test('SSR page renders server-fetched data', async ({ page }) => {
    await page.goto('/blog');

    // useFetch/useAsyncData runs on the server -- content is in the initial HTML
    await expect(page.getByRole('article')).toHaveCount(10);
    await expect(page.getByRole('article').first()).toContainText(/\w+/);
  });

  test('Nuxt server route returns correct data', async ({ request }) => {
    const response = await request.get('/api/products');

    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    expect(body).toBeInstanceOf(Array);
    expect(body[0]).toHaveProperty('id');
  });

  test('Nuxt middleware redirects unauthenticated users', async ({ page }) => {
    await page.goto('/admin');

    // definePageMeta({ middleware: 'auth' }) in the page triggers redirect
    await expect(page).toHaveURL(/\/login/);
  });

  test('NuxtLink enables client-side navigation', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      (window as any).__testMarker = 'no-reload';
    });

    await page.getByRole('link', { name: 'Blog' }).click();
    await page.waitForURL('/blog');

    const marker = await page.evaluate(() => (window as any).__testMarker);
    expect(marker).toBe('no-reload');
  });

  test('useHead sets meta tags for SEO', async ({ page }) => {
    await page.goto('/blog/my-post');

    const title = await page.title();
    expect(title).toContain('My Post');

    const description = await page.locator('meta[name="description"]').getAttribute('content');
    expect(description).toBeTruthy();
    expect(description!.length).toBeGreaterThan(50);
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test.describe('Nuxt-specific patterns', () => {
  test('SSR page renders server-fetched data', async ({ page }) => {
    await page.goto('/blog');

    await expect(page.getByRole('article')).toHaveCount(10);
    await expect(page.getByRole('article').first()).toContainText(/\w+/);
  });

  test('Nuxt server route returns correct data', async ({ request }) => {
    const response = await request.get('/api/products');

    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    expect(body).toBeInstanceOf(Array);
    expect(body[0]).toHaveProperty('id');
  });

  test('Nuxt middleware redirects unauthenticated users', async ({ page }) => {
    await page.goto('/admin');

    await expect(page).toHaveURL(/\/login/);
  });

  test('NuxtLink enables client-side navigation', async ({ page }) => {
    await page.goto('/');

    await page.evaluate(() => {
      window.__testMarker = 'no-reload';
    });

    await page.getByRole('link', { name: 'Blog' }).click();
    await page.waitForURL('/blog');

    const marker = await page.evaluate(() => window.__testMarker);
    expect(marker).toBe('no-reload');
  });
});

Framework-Specific Tips

Vue DevTools and Playwright

Vue DevTools is a browser extension. It does not interfere with Playwright tests since Playwright launches its own browser profile without extensions. Do not rely on Vue DevTools for debugging in CI -- use Playwright traces instead.

Testing v-model Two-Way Binding

v-model on form inputs works through standard HTML events. Playwright's fill(), check(), selectOption() methods trigger the correct events automatically. No special handling is needed.

// This works for any v-model input -- no special Vue handling needed
await page.getByLabel('Username').fill('janedoe');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('combobox', { name: 'Role' }).selectOption('admin');

Vue vs Nuxt: Configuration Differences

Aspect Vue 3 (Vite) Nuxt 3
Default port 5173 3000
Dev command npm run dev (vite) npx nuxi dev
Build + preview npm run build && npx vite preview npx nuxi build && npx nuxi preview
SSR Optional (vue-server-renderer) Built-in
API routes External backend /server/api/ built-in
Environment variables VITE_* prefix NUXT_PUBLIC_* prefix for client, NUXT_* for server
File-based routing No (uses vue-router config) Yes (automatic from /pages/)

Component Testing: Providing Pinia and Router

When using @playwright/experimental-ct-vue, components that depend on Pinia or Vue Router need these dependencies provided. Configure the test wrapper:

// playwright/index.ts (component testing setup)
import { beforeMount } from '@playwright/experimental-ct-vue/hooks';
import { createPinia } from 'pinia';
import { createMemoryHistory, createRouter } from 'vue-router';

beforeMount(async ({ app, hooksConfig }) => {
  // Install Pinia for all component tests
  const pinia = createPinia();
  app.use(pinia);

  // Install a minimal router if the component uses <RouterLink> or useRoute()
  if (hooksConfig?.routes) {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: hooksConfig.routes,
    });
    app.use(router);
  }
});

Handling Vue Warnings in Tests

Vue emits runtime warnings (prop validation, missing components, etc.) that can indicate real issues:

test('no Vue warnings during render', async ({ page }) => {
  const warnings: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {
      warnings.push(msg.text());
    }
  });

  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  expect(warnings).toEqual([]);
});

Anti-Patterns

Don't Do This Problem Do This Instead
page.evaluate(() => app.__vue_app__.config.globalProperties.$store) Accesses Vue internals; breaks on upgrades; tests implementation Assert on the UI that state produces
page.locator('[data-v-abc123]') (scoped style hash) Vue generates random scoped attribute hashes; changes on every build Use getByRole, getByText, getByTestId
Import .vue files in E2E tests E2E tests run in Node.js; .vue files need Vite/Webpack compilation Use @playwright/experimental-ct-vue for component tests
page.waitForTimeout(300) to wait for transition to finish Transition durations vary; arbitrary waits are fragile await expect(locator).toBeVisible() auto-waits through transitions
Mock Pinia stores by patching window.__pinia Fragile; depends on Pinia internals; may not trigger reactivity Control state through UI interactions or mock the API responses the store consumes
Test composables by calling them via page.evaluate Composables rely on Vue's setup context; calling them outside a component fails Test composables through the components that use them, or unit test with Vitest
Use page.locator('.v-btn') for Vuetify components Vuetify class names are internal and change between versions page.getByRole('button', { name: 'Submit' }) works regardless of the component library
Skip testing keyboard navigation for custom components Vue component libraries often have incomplete keyboard support Test Tab, Enter, Escape, ArrowDown/Up on dropdowns, modals, tabs
Run Nuxt dev server in CI Dev mode includes hot reload overhead, slower builds, development warnings Use npx nuxi build && npx nuxi preview in CI

Related