Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
frameworks/vue.md
1# Vue and Nuxt Testing23## Table of Contents451. [Commands](#commands)62. [Configuration](#configuration)73. [Patterns](#patterns)84. [Vue vs Nuxt Differences](#vue-vs-nuxt-differences)95. [Component Testing Dependencies](#component-testing-dependencies)106. [Testing v-model](#testing-v-model)117. [Capturing Vue Warnings](#capturing-vue-warnings)128. [Anti-Patterns](#anti-patterns)1314> **When to use**: Testing Vue 3 applications with composition API, Pinia stores, Vue Router, Nuxt 3 apps, Teleport portals, and transitions.1516## Commands1718```bash19npm init playwright@latest20npm install -D @playwright/experimental-ct-vue21npx playwright test22npx playwright test -c playwright-ct.config.ts23```2425## Configuration2627### Vue with Vite2829```typescript30// playwright.config.ts31import { defineConfig, devices } from '@playwright/test';3233export default defineConfig({34testDir: './tests/e2e',35testMatch: '**/*.spec.ts',36fullyParallel: true,37forbidOnly: !!process.env.CI,38retries: process.env.CI ? 2 : 0,39workers: process.env.CI ? '50%' : undefined,4041use: {42baseURL: 'http://localhost:5173',43trace: 'on-first-retry',44screenshot: 'only-on-failure',45},4647projects: [48{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },49{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },50{ name: 'mobile', use: { ...devices['iPhone 14'] } },51],5253webServer: {54command: process.env.CI55? 'npm run build && npx vite preview --port 5173'56: 'npm run dev',57url: 'http://localhost:5173',58reuseExistingServer: !process.env.CI,59timeout: 120_000,60},61});62```6364### Nuxt 36566Nuxt uses port 3000 and requires a build step before testing.6768```typescript69// playwright.config.ts70import { defineConfig, devices } from '@playwright/test';7172export default defineConfig({73testDir: './tests/e2e',74testMatch: '**/*.spec.ts',75fullyParallel: true,76forbidOnly: !!process.env.CI,77retries: process.env.CI ? 2 : 0,7879use: {80baseURL: 'http://localhost:3000',81trace: 'on-first-retry',82screenshot: 'only-on-failure',83},8485projects: [86{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },87],8889webServer: {90command: process.env.CI91? 'npx nuxi build && npx nuxi preview'92: 'npx nuxi dev',93url: 'http://localhost:3000',94reuseExistingServer: !process.env.CI,95timeout: 120_000,96env: {97NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',98},99},100});101```102103### Component Testing104105```typescript106// playwright-ct.config.ts107import { defineConfig, devices } from '@playwright/experimental-ct-vue';108109export default defineConfig({110testDir: './tests/components',111testMatch: '**/*.ct.ts',112113use: {114trace: 'on-first-retry',115ctPort: 3100,116},117118projects: [119{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },120],121});122```123124## Patterns125126### Component Testing with Experimental CT127128**Use when**: Testing complex interactive Vue components in isolation (data tables, form components, custom dropdowns).129130**Avoid when**: Component depends heavily on Pinia stores, Vue Router, or backend data—use E2E tests instead.131132```typescript133// tests/components/Stepper.ct.ts134import { test, expect } from '@playwright/experimental-ct-vue';135import Stepper from '../../src/components/Stepper.vue';136137test('increments value on button click', async ({ mount }) => {138const component = await mount(Stepper, {139props: { value: 0 },140});141142await expect(component.getByText('Value: 0')).toBeVisible();143await component.getByRole('button', { name: '+' }).click();144await expect(component.getByText('Value: 1')).toBeVisible();145});146147test('emits change event', async ({ mount }) => {148const changes: number[] = [];149const component = await mount(Stepper, {150props: { value: 10 },151on: {152change: (val: number) => changes.push(val),153},154});155156await component.getByRole('button', { name: '+' }).click();157await component.getByRole('button', { name: '+' }).click();158159expect(changes).toEqual([11, 12]);160});161162test('renders slot content', async ({ mount }) => {163const component = await mount(Stepper, {164props: { value: 0 },165slots: {166default: '<span class="label">Quantity</span>',167},168});169170await expect(component.getByText('Quantity')).toBeVisible();171});172```173174### Pinia Store Testing Through UI175176**Use when**: Verifying Pinia stores produce correct UI behavior. If the UI is correct, the store is correct.177178**Avoid when**: Testing pure store logic with no UI side effect—use unit tests with Vitest.179180```typescript181import { test, expect } from '@playwright/test';182183test.describe('shopping cart store', () => {184test('adding products updates cart badge', async ({ page }) => {185await page.goto('/shop');186187const badge = page.getByTestId('cart-badge');188await expect(badge).toHaveText('0');189190await page.getByRole('listitem')191.filter({ hasText: 'Hoodie' })192.getByRole('button', { name: 'Add' })193.click();194await expect(badge).toHaveText('1');195196await page.getByRole('listitem')197.filter({ hasText: 'Cap' })198.getByRole('button', { name: 'Add' })199.click();200await expect(badge).toHaveText('2');201202await page.getByRole('link', { name: 'Cart' }).click();203await page.waitForURL('/cart');204205await expect(page.getByText('Hoodie')).toBeVisible();206await expect(page.getByText('Cap')).toBeVisible();207});208209test('persisted state survives reload', async ({ page }) => {210await page.goto('/shop');211212await page.getByRole('listitem')213.filter({ hasText: 'Hoodie' })214.getByRole('button', { name: 'Add' })215.click();216217await page.reload();218219await expect(page.getByTestId('cart-badge')).toHaveText('1');220});221});222```223224### Vue Router Navigation225226**Use when**: Testing client-side routing, navigation guards, URL parameters, browser history.227228```typescript229import { test, expect } from '@playwright/test';230231test.describe('router navigation', () => {232test('client-side navigation preserves state', async ({ page }) => {233await page.goto('/');234235await page.evaluate(() => {236(window as any).__marker = 'spa';237});238239await page.getByRole('link', { name: 'Shop' }).click();240await page.waitForURL('/shop');241242const marker = await page.evaluate(() => (window as any).__marker);243expect(marker).toBe('spa');244});245246test('dynamic route params render content', async ({ page }) => {247await page.goto('/items/99');248249await expect(page.getByRole('heading', { level: 1 })).toBeVisible();250await expect(page.getByText('Item #99')).toBeVisible();251});252253test('navigation guard redirects unauthorized users', async ({ page }) => {254await page.goto('/admin/dashboard');255256await expect(page).toHaveURL(/\/login/);257await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();258});259260test('browser history navigation works', async ({ page }) => {261await page.goto('/');262await page.getByRole('link', { name: 'Shop' }).click();263await page.waitForURL('/shop');264await page.getByRole('link', { name: 'Contact' }).click();265await page.waitForURL('/contact');266267await page.goBack();268await expect(page).toHaveURL(/\/shop/);269270await page.goBack();271await expect(page).toHaveURL(/\/$/);272273await page.goForward();274await expect(page).toHaveURL(/\/shop/);275});276277test('query params update reactive state', async ({ page }) => {278await page.goto('/items?sort=price&type=clothing');279280await expect(page.getByRole('heading', { name: 'Clothing' })).toBeVisible();281282await page.getByRole('combobox', { name: 'Sort' }).selectOption('name');283await expect(page).toHaveURL(/sort=name/);284});285286test('catch-all route shows 404', async ({ page }) => {287await page.goto('/nonexistent-page');288289await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();290});291});292```293294### Teleport Components295296**Use when**: Testing components rendered via `<Teleport>` (modals, notifications, overlay menus).297298```typescript299import { test, expect } from '@playwright/test';300301test.describe('teleported elements', () => {302test('modal is visible and interactive', async ({ page }) => {303await page.goto('/items');304305await page.getByRole('button', { name: 'Remove' }).first().click();306307const dialog = page.getByRole('dialog', { name: 'Confirm' });308await expect(dialog).toBeVisible();309310await dialog.getByRole('button', { name: 'Cancel' }).click();311await expect(dialog).toBeHidden();312});313314test('notification auto-dismisses', async ({ page }) => {315await page.goto('/profile');316317await page.getByRole('button', { name: 'Update' }).click();318319const alert = page.getByRole('alert');320await expect(alert).toBeVisible();321await expect(alert).toContainText('Saved');322323await expect(alert).toBeHidden({ timeout: 10_000 });324});325326test('dropdown closes on outside click', async ({ page }) => {327await page.goto('/home');328329await page.getByRole('button', { name: 'Menu' }).click();330331const menu = page.getByRole('menu');332await expect(menu).toBeVisible();333334await page.locator('body').click({ position: { x: 10, y: 10 } });335await expect(menu).toBeHidden();336});337});338```339340### Transitions and Animations341342**Use when**: Verifying `<Transition>` and `<TransitionGroup>` work correctly. Focus on end state, not animation details.343344```typescript345import { test, expect } from '@playwright/test';346347test.describe('transitions', () => {348test('item appears after add', async ({ page }) => {349await page.goto('/tasks');350351await page.getByRole('textbox', { name: 'Task' }).fill('Write tests');352await page.getByRole('button', { name: 'Add' }).click();353354await expect(page.getByText('Write tests')).toBeVisible();355});356357test('item disappears after delete', async ({ page }) => {358await page.goto('/tasks');359360await page.getByRole('textbox', { name: 'Task' }).fill('Temp item');361await page.getByRole('button', { name: 'Add' }).click();362await expect(page.getByText('Temp item')).toBeVisible();363364await page.getByRole('listitem')365.filter({ hasText: 'Temp item' })366.getByRole('button', { name: 'Remove' })367.click();368369await expect(page.getByText('Temp item')).toBeHidden();370});371372test('disable animations for faster tests', async ({ page }) => {373await page.addStyleTag({374content: `375*, *::before, *::after {376animation-duration: 0s !important;377animation-delay: 0s !important;378transition-duration: 0s !important;379transition-delay: 0s !important;380}381`,382});383384await page.goto('/tasks');385386await page.getByRole('textbox', { name: 'Task' }).fill('Quick task');387await page.getByRole('button', { name: 'Add' }).click();388389await expect(page.getByText('Quick task')).toBeVisible();390});391});392```393394### Composition API Components395396**Use when**: Testing components with `<script setup>` or `setup()`. From Playwright's perspective, Composition API and Options API are identical.397398```typescript399import { test, expect } from '@playwright/test';400401test.describe('composition API', () => {402test('computed properties update reactively', async ({ page }) => {403await page.goto('/pricing');404405await page.getByLabel('Amount').fill('50');406await page.getByLabel('Qty').fill('4');407408await expect(page.getByTestId('sum')).toHaveText('$200.00');409410await page.getByLabel('Discount').fill('20');411await expect(page.getByTestId('sum')).toHaveText('$160.00');412});413414test('watcher triggers on change', async ({ page }) => {415await page.goto('/preferences');416417await page.getByRole('combobox', { name: 'Locale' }).selectOption('de');418419await expect(page.getByRole('heading', { name: 'Einstellungen' })).toBeVisible();420});421422test('composable provides debounced search', async ({ page }) => {423await page.goto('/shop');424425const input = page.getByRole('textbox', { name: 'Search' });426await input.pressSequentially('hoodie', { delay: 50 });427428await expect(page.getByRole('listitem')).toHaveCount(2);429await expect(page.getByText('Black Hoodie')).toBeVisible();430});431432test('provide/inject updates all consumers', async ({ page }) => {433await page.goto('/home');434435await page.getByRole('switch', { name: 'Dark theme' }).click();436437await expect(page.locator('body')).toHaveClass(/dark/);438});439});440```441442### Nuxt-Specific Patterns443444**Use when**: Testing Nuxt 3 with SSR, auto-imports, server routes, and middleware.445446```typescript447import { test, expect } from '@playwright/test';448449test.describe('nuxt features', () => {450test('SSR renders server-fetched data', async ({ page }) => {451await page.goto('/posts');452453await expect(page.getByRole('article')).toHaveCount(10);454await expect(page.getByRole('article').first()).toContainText(/\w+/);455});456457test('server route returns data', async ({ request }) => {458const response = await request.get('/api/items');459460expect(response.ok()).toBeTruthy();461const data = await response.json();462expect(data).toBeInstanceOf(Array);463expect(data[0]).toHaveProperty('id');464});465466test('middleware redirects unauthorized', async ({ page }) => {467await page.goto('/admin');468469await expect(page).toHaveURL(/\/login/);470});471472test('NuxtLink enables SPA navigation', async ({ page }) => {473await page.goto('/');474475await page.evaluate(() => {476(window as any).__marker = 'spa';477});478479await page.getByRole('link', { name: 'Posts' }).click();480await page.waitForURL('/posts');481482const marker = await page.evaluate(() => (window as any).__marker);483expect(marker).toBe('spa');484});485486test('useHead sets meta tags', async ({ page }) => {487await page.goto('/posts/hello-world');488489const title = await page.title();490expect(title).toContain('Hello World');491492const desc = await page.locator('meta[name="description"]').getAttribute('content');493expect(desc).toBeTruthy();494expect(desc!.length).toBeGreaterThan(50);495});496});497```498499## Vue vs Nuxt Differences500501| Aspect | Vue 3 (Vite) | Nuxt 3 |502| --- | --- | --- |503| Default port | `5173` | `3000` |504| Dev command | `npm run dev` | `npx nuxi dev` |505| Build + preview | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview` |506| SSR | Optional | Built-in |507| API routes | External backend | `/server/api/` built-in |508| Env variables | `VITE_*` prefix | `NUXT_PUBLIC_*` (client), `NUXT_*` (server) |509| File-based routing | No | Yes |510511## Component Testing Dependencies512513Components depending on Pinia or Vue Router need these provided:514515```typescript516// playwright/index.ts517import { beforeMount } from '@playwright/experimental-ct-vue/hooks';518import { createPinia } from 'pinia';519import { createMemoryHistory, createRouter } from 'vue-router';520521beforeMount(async ({ app, hooksConfig }) => {522const pinia = createPinia();523app.use(pinia);524525if (hooksConfig?.routes) {526const router = createRouter({527history: createMemoryHistory(),528routes: hooksConfig.routes,529});530app.use(router);531}532});533```534535## Testing v-model536537`v-model` works through standard HTML events. Playwright methods trigger correct events automatically:538539```typescript540await page.getByLabel('Email').fill('[email protected]');541await page.getByRole('checkbox', { name: 'Subscribe' }).check();542await page.getByRole('combobox', { name: 'Country' }).selectOption('US');543```544545## Capturing Vue Warnings546547```typescript548test('no Vue warnings during render', async ({ page }) => {549const warnings: string[] = [];550page.on('console', (msg) => {551if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {552warnings.push(msg.text());553}554});555556await page.goto('/home');557await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();558559expect(warnings).toEqual([]);560});561```562563## Anti-Patterns564565| Avoid | Problem | Instead |566| --- | --- | --- |567| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades | Assert on UI that state produces |568| `page.locator('[data-v-abc123]')` | Scoped style hashes change on every build | Use `getByRole`, `getByText`, `getByTestId` |569| Import `.vue` files in E2E tests | E2E tests run in Node.js; `.vue` needs compilation | Use `@playwright/experimental-ct-vue` for component tests |570| `page.waitForTimeout(300)` for transitions | Arbitrary waits are fragile | `await expect(locator).toBeVisible()` auto-waits |571| Mock Pinia by patching `window.__pinia` | Fragile; may not trigger reactivity | Control state through UI or mock API responses |572| Test composables via `page.evaluate` | Composables need Vue's setup context | Test through components or unit test with Vitest |573| `page.locator('.v-btn')` for Vuetify | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |574| Run Nuxt dev server in CI | Dev mode is slower with hot reload overhead | Use `npx nuxi build && npx nuxi preview` |575