# Vue and Nuxt Testing
## Table of Contents
1. [Commands](#commands)
2. [Configuration](#configuration)
3. [Patterns](#patterns)
4. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)
5. [Component Testing Dependencies](#component-testing-dependencies)
6. [Testing v-model](#testing-v-model)
7. [Capturing Vue Warnings](#capturing-vue-warnings)
8. [Anti-Patterns](#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: 'Quantity',
},
});
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 `` (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 `` and `` 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 `