Back to Sanity

Vue and Nuxt Testing

.agents/skills/playwright-best-practices/frameworks/vue.md

5.24.017.5 KB
Original Source

Vue and Nuxt Testing

Table of Contents

  1. Commands
  2. Configuration
  3. Patterns
  4. Vue vs Nuxt Differences
  5. Component Testing Dependencies
  6. Testing v-model
  7. Capturing Vue Warnings
  8. Anti-Patterns

When to use: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.

Commands

bash
npm init playwright@latest
npm install -D @playwright/experimental-ct-vue
npx playwright test
npx playwright test -c playwright-ct.config.ts

Configuration

Vue with 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,
  },
})

Nuxt 3

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

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',
    },
  },
})

Component Testing

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']}}],
})

Patterns

Component Testing with Experimental CT

Use when: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).

Avoid when: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.

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

test('increments value on button click', async ({mount}) => {
  const component = await mount(Stepper, {
    props: {value: 0},
  })

  await expect(component.getByText('Value: 0')).toBeVisible()
  await component.getByRole('button', {name: '+'}).click()
  await expect(component.getByText('Value: 1')).toBeVisible()
})

test('emits change event', async ({mount}) => {
  const changes: number[] = []
  const component = await mount(Stepper, {
    props: {value: 10},
    on: {
      change: (val: number) => changes.push(val),
    },
  })

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

  expect(changes).toEqual([11, 12])
})

test('renders slot content', async ({mount}) => {
  const component = await mount(Stepper, {
    props: {value: 0},
    slots: {
      default: '<span class="label">Quantity</span>',
    },
  })

  await expect(component.getByText('Quantity')).toBeVisible()
})

Pinia Store Testing Through UI

Use when: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.

Avoid when: Testing pure store logic with no UI side effect—use unit tests with Vitest.

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

test.describe('shopping cart store', () => {
  test('adding products updates cart badge', async ({page}) => {
    await page.goto('/shop')

    const badge = page.getByTestId('cart-badge')
    await expect(badge).toHaveText('0')

    await page
      .getByRole('listitem')
      .filter({hasText: 'Hoodie'})
      .getByRole('button', {name: 'Add'})
      .click()
    await expect(badge).toHaveText('1')

    await page
      .getByRole('listitem')
      .filter({hasText: 'Cap'})
      .getByRole('button', {name: 'Add'})
      .click()
    await expect(badge).toHaveText('2')

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

    await expect(page.getByText('Hoodie')).toBeVisible()
    await expect(page.getByText('Cap')).toBeVisible()
  })

  test('persisted state survives reload', async ({page}) => {
    await page.goto('/shop')

    await page
      .getByRole('listitem')
      .filter({hasText: 'Hoodie'})
      .getByRole('button', {name: 'Add'})
      .click()

    await page.reload()

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

Vue Router Navigation

Use when: Testing client-side routing, navigation guards, URL parameters, browser history.

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

test.describe('router navigation', () => {
  test('client-side navigation preserves state', async ({page}) => {
    await page.goto('/')

    await page.evaluate(() => {
      ;(window as any).__marker = 'spa'
    })

    await page.getByRole('link', {name: 'Shop'}).click()
    await page.waitForURL('/shop')

    const marker = await page.evaluate(() => (window as any).__marker)
    expect(marker).toBe('spa')
  })

  test('dynamic route params render content', async ({page}) => {
    await page.goto('/items/99')

    await expect(page.getByRole('heading', {level: 1})).toBeVisible()
    await expect(page.getByText('Item #99')).toBeVisible()
  })

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

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

  test('browser history navigation works', async ({page}) => {
    await page.goto('/')
    await page.getByRole('link', {name: 'Shop'}).click()
    await page.waitForURL('/shop')
    await page.getByRole('link', {name: 'Contact'}).click()
    await page.waitForURL('/contact')

    await page.goBack()
    await expect(page).toHaveURL(/\/shop/)

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

    await page.goForward()
    await expect(page).toHaveURL(/\/shop/)
  })

  test('query params update reactive state', async ({page}) => {
    await page.goto('/items?sort=price&type=clothing')

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

    await page.getByRole('combobox', {name: 'Sort'}).selectOption('name')
    await expect(page).toHaveURL(/sort=name/)
  })

  test('catch-all route shows 404', async ({page}) => {
    await page.goto('/nonexistent-page')

    await expect(page.getByRole('heading', {name: 'Not Found'})).toBeVisible()
  })
})

Teleport Components

Use when: Testing components rendered via <Teleport> (modals, notifications, overlay menus).

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

test.describe('teleported elements', () => {
  test('modal is visible and interactive', async ({page}) => {
    await page.goto('/items')

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

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

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

  test('notification auto-dismisses', async ({page}) => {
    await page.goto('/profile')

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

    const alert = page.getByRole('alert')
    await expect(alert).toBeVisible()
    await expect(alert).toContainText('Saved')

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

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

    await page.getByRole('button', {name: '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()
  })
})

Transitions and Animations

Use when: Verifying <Transition> and <TransitionGroup> work correctly. Focus on end state, not animation details.

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

test.describe('transitions', () => {
  test('item appears after add', async ({page}) => {
    await page.goto('/tasks')

    await page.getByRole('textbox', {name: 'Task'}).fill('Write tests')
    await page.getByRole('button', {name: 'Add'}).click()

    await expect(page.getByText('Write tests')).toBeVisible()
  })

  test('item disappears after delete', async ({page}) => {
    await page.goto('/tasks')

    await page.getByRole('textbox', {name: 'Task'}).fill('Temp item')
    await page.getByRole('button', {name: 'Add'}).click()
    await expect(page.getByText('Temp item')).toBeVisible()

    await page
      .getByRole('listitem')
      .filter({hasText: 'Temp item'})
      .getByRole('button', {name: 'Remove'})
      .click()

    await expect(page.getByText('Temp item')).toBeHidden()
  })

  test('disable animations for faster 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('/tasks')

    await page.getByRole('textbox', {name: 'Task'}).fill('Quick task')
    await page.getByRole('button', {name: 'Add'}).click()

    await expect(page.getByText('Quick task')).toBeVisible()
  })
})

Composition API Components

Use when: Testing components with <script setup> or setup(). From Playwright's perspective, Composition API and Options API are identical.

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

test.describe('composition API', () => {
  test('computed properties update reactively', async ({page}) => {
    await page.goto('/pricing')

    await page.getByLabel('Amount').fill('50')
    await page.getByLabel('Qty').fill('4')

    await expect(page.getByTestId('sum')).toHaveText('$200.00')

    await page.getByLabel('Discount').fill('20')
    await expect(page.getByTestId('sum')).toHaveText('$160.00')
  })

  test('watcher triggers on change', async ({page}) => {
    await page.goto('/preferences')

    await page.getByRole('combobox', {name: 'Locale'}).selectOption('de')

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

  test('composable provides debounced search', async ({page}) => {
    await page.goto('/shop')

    const input = page.getByRole('textbox', {name: 'Search'})
    await input.pressSequentially('hoodie', {delay: 50})

    await expect(page.getByRole('listitem')).toHaveCount(2)
    await expect(page.getByText('Black Hoodie')).toBeVisible()
  })

  test('provide/inject updates all consumers', async ({page}) => {
    await page.goto('/home')

    await page.getByRole('switch', {name: 'Dark theme'}).click()

    await expect(page.locator('body')).toHaveClass(/dark/)
  })
})

Nuxt-Specific Patterns

Use when: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.

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

test.describe('nuxt features', () => {
  test('SSR renders server-fetched data', async ({page}) => {
    await page.goto('/posts')

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

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

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

  test('middleware redirects unauthorized', async ({page}) => {
    await page.goto('/admin')

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

  test('NuxtLink enables SPA navigation', async ({page}) => {
    await page.goto('/')

    await page.evaluate(() => {
      ;(window as any).__marker = 'spa'
    })

    await page.getByRole('link', {name: 'Posts'}).click()
    await page.waitForURL('/posts')

    const marker = await page.evaluate(() => (window as any).__marker)
    expect(marker).toBe('spa')
  })

  test('useHead sets meta tags', async ({page}) => {
    await page.goto('/posts/hello-world')

    const title = await page.title()
    expect(title).toContain('Hello World')

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

Vue vs Nuxt Differences

AspectVue 3 (Vite)Nuxt 3
Default port51733000
Dev commandnpm run devnpx nuxi dev
Build + previewnpm run build && npx vite previewnpx nuxi build && npx nuxi preview
SSROptionalBuilt-in
API routesExternal backend/server/api/ built-in
Env variablesVITE_* prefixNUXT_PUBLIC_* (client), NUXT_* (server)
File-based routingNoYes

Component Testing Dependencies

Components depending on Pinia or Vue Router need these provided:

typescript
// playwright/index.ts
import {beforeMount} from '@playwright/experimental-ct-vue/hooks'
import {createPinia} from 'pinia'
import {createMemoryHistory, createRouter} from 'vue-router'

beforeMount(async ({app, hooksConfig}) => {
  const pinia = createPinia()
  app.use(pinia)

  if (hooksConfig?.routes) {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: hooksConfig.routes,
    })
    app.use(router)
  }
})

Testing v-model

v-model works through standard HTML events. Playwright methods trigger correct events automatically:

typescript
await page.getByLabel('Email').fill('[email protected]')
await page.getByRole('checkbox', {name: 'Subscribe'}).check()
await page.getByRole('combobox', {name: 'Country'}).selectOption('US')

Capturing Vue Warnings

typescript
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('/home')
  await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible()

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

Anti-Patterns

AvoidProblemInstead
page.evaluate(() => app.__vue_app__.config.globalProperties.$store)Accesses Vue internals; breaks on upgradesAssert on UI that state produces
page.locator('[data-v-abc123]')Scoped style hashes change on every buildUse getByRole, getByText, getByTestId
Import .vue files in E2E testsE2E tests run in Node.js; .vue needs compilationUse @playwright/experimental-ct-vue for component tests
page.waitForTimeout(300) for transitionsArbitrary waits are fragileawait expect(locator).toBeVisible() auto-waits
Mock Pinia by patching window.__piniaFragile; may not trigger reactivityControl state through UI or mock API responses
Test composables via page.evaluateComposables need Vue's setup contextTest through components or unit test with Vitest
page.locator('.v-btn') for VuetifyClass names change between versionspage.getByRole('button', { name: 'Submit' })
Run Nuxt dev server in CIDev mode is slower with hot reload overheadUse npx nuxi build && npx nuxi preview