# 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](configuration.md), [core/locators.md](locators.md)
## Quick Reference
```bash
# 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**
```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**
```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**
```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**
```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**
```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**
```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**
```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: 'Items in cart',
},
})
await expect(component.getByText("Items in cart")).toBeVisible()
})
```
**JavaScript**
```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: 'Items in cart',
},
})
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**
```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**
```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**
```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**
```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 `` -- 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**
```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 with
// 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**
```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 `` and `` 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**
```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**
```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 (`