# 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 (`