Files
2026-07-13 21:36:47 +08:00

39 KiB
Raw Permalink Blame History

使用 Playwright 测试 Vue 应用

适用场景:测试 Vue 3 应用程序,包括组合式 API 组件、Pinia 存储、Vue Router 导航、Nuxt.js 应用、Teleport 传送门以及过渡动画。涵盖端到端测试和实验性的 @playwright/experimental-ct-vue 组件测试。 前置要求core/configuration.mdcore/locators.md

快速参考

# 在 Vue 项目中安装 Playwright
npm init playwright@latest

# 安装组件测试(实验性)
npm install -D @playwright/experimental-ct-vue

# 运行 E2E 测试
npx playwright test

# 运行组件测试
npx playwright test -c playwright-ct.config.ts

# 使用有头浏览器和检查器调试测试
npx playwright test tests/home.spec.ts --headed --debug

设置

Vue (Vite) 的 Playwright 配置

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

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

组件测试配置

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

// 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 设置

Nuxt 在测试前需要构建步骤,默认使用 3000 端口。

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

// 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",
    },
  },
})

模式

使用 @playwright/experimental-ct-vue 进行组件测试

适用场景:在隔离环境中测试复杂的交互式 Vue 组件——数据表格、表单组件、自定义选择下拉框、富文本编辑器。该组件需要真实浏览器但不需要完整的应用程序。 避免场景:组件严重依赖 Pinia 存储、Vue Router 或后端数据。请改用 E2E 测试,或在组件测试设置中提供这些依赖。

TypeScript

// tests/components/Counter.ct.ts
import { test, expect } from "@playwright/experimental-ct-vue"
import Counter from "../../src/components/Counter.vue"

test("使用初始计数渲染并可递增", 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("计数变化时触发 update 事件", 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("遵守 max 属性", 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()

  // 达到最大值——按钮应被禁用
  await expect(component.getByRole("button", { name: "Increment" })).toBeDisabled()
})

test("使用插槽内容渲染", async ({ mount }) => {
  const component = await mount(Counter, {
    props: { initialCount: 0 },
    slots: {
      default: '<span class="custom-label">Items in cart</span>',
    },
  })

  await expect(component.getByText("Items in cart")).toBeVisible()
})

JavaScript

// tests/components/Counter.ct.js
const { test, expect } = require("@playwright/experimental-ct-vue")
const Counter = require("../../src/components/Counter.vue")

test("使用初始计数渲染并可递增", 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("计数变化时触发 update 事件", 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("使用插槽内容渲染", async ({ mount }) => {
  const component = await mount(Counter, {
    props: { initialCount: 0 },
    slots: {
      default: '<span class="custom-label">Items in cart</span>',
    },
  })

  await expect(component.getByText("Items in cart")).toBeVisible()
})

通过 UI 测试 Pinia 存储

适用场景:验证 Pinia 存储是否产生正确的 UI 行为。Playwright 测试渲染输出,而不是直接测试存储。如果 UI 正确,那么存储就是正确的。 避免场景:测试纯存储逻辑(getter、无 UI 副作用的 action)——应使用 Vitest 进行单元测试。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("Pinia 购物车存储(通过 UI 测试)", () => {
  test("添加商品会更新整个应用的购物车", async ({ page }) => {
    await page.goto("/products")

    const cartBadge = page.getByTestId("cart-count")

    // 初始为空
    await expect(cartBadge).toHaveText("0")

    // 添加第一个商品——触发 Pinia 存储 action
    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()
    await expect(page.getByText("Total: $29.98")).toBeVisible()
  })

  test("从购物车移除商品会更新总价", async ({ page }) => {
    // 导航到预填充的购物车(通过 API 播种或通过导航添加商品)
    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("使用 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")
  })
})

test.describe("Pinia 认证存储(通过 UI 测试)", () => {
  test("登录会更新所有组件的认证状态", 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")

    // 头部更新(使用认证存储)
    await expect(page.getByText("user@example.com")).toBeVisible()
    // 导航更新(使用认证存储实现条件路由)
    await expect(page.getByRole("link", { name: "Admin" })).toBeVisible()
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("Pinia 购物车存储(通过 UI 测试)", () => {
  test("添加商品会更新整个应用的购物车", 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("使用 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")
  })
})

测试 Vue Router 导航

适用场景:测试使用 Vue Router 的客户端路由。验证路由切换、导航守卫、URL 参数和浏览器历史行为。 避免场景:测试 Nuxt.js 基于文件的路由——模式类似,但 Nuxt 有额外的服务端关注点。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("Vue Router 导航", () => {
  test("客户端导航不会导致完整页面刷新", 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("动态路由参数渲染正确内容", async ({ page }) => {
    await page.goto("/products/42")

    await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
    await expect(page.getByText("Product #42")).toBeVisible()
  })

  test("导航守卫重定向未认证用户", async ({ page }) => {
    // 访问被 router.beforeEach 守卫的路由
    await page.goto("/admin/users")

    // 守卫应重定向到登录页
    await expect(page).toHaveURL(/\/login/)
    await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
  })

  test("命名路由和嵌套视图正确渲染", async ({ page }) => {
    await page.goto("/settings")

    // 父布局
    await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
    await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible()

    // 默认子路由
    await expect(page.getByRole("heading", { name: "General", level: 2 })).toBeVisible()

    // 导航到同级子路由
    await page.getByRole("link", { name: "Notifications" }).click()
    await page.waitForURL("/settings/notifications")

    // 父布局保持不变,子内容发生变化
    await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
    await expect(page.getByRole("heading", { name: "Notifications", level: 2 })).toBeVisible()
  })

  test("浏览器后退/前进与 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("查询参数更新响应式计算属性", async ({ page }) => {
    await page.goto("/products?sort=price-asc&category=electronics")

    await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()

    // 通过 UI 更改排序方式(更新路由查询参数)
    await page.getByRole("combobox", { name: "Sort by" }).selectOption("name-asc")
    await expect(page).toHaveURL(/sort=name-asc/)
  })

  test("404 通配路由显示未找到页面", 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

const { test, expect } = require("@playwright/test")

test.describe("Vue Router 导航", () => {
  test("客户端导航不会导致完整页面刷新", 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("动态路由参数渲染正确内容", async ({ page }) => {
    await page.goto("/products/42")

    await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
    await expect(page.getByText("Product #42")).toBeVisible()
  })

  test("导航守卫重定向未认证用户", async ({ page }) => {
    await page.goto("/admin/users")

    await expect(page).toHaveURL(/\/login/)
    await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
  })

  test("浏览器后退/前进与 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(/\/$/)
  })
})

测试 Teleport(传送门)

适用场景:测试通过 Vue 的 <Teleport> 渲染的组件——模态框、通知、覆盖菜单。Teleport 将 DOM 元素移动到不同位置(通常是 body),但 Playwright 能够看到整个文档。 避免场景:组件未被传送——它在父组件内联渲染。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("Teleport 组件", () => {
  test("传送到 body 的模态框可见并可交互", async ({ page }) => {
    await page.goto("/products")

    await page.getByRole("button", { name: "Delete" }).first().click()

    // 模态框通过 <Teleport to="body"> 传送到 <body>
    // Playwright 像对待其他元素一样对待它
    const modal = page.getByRole("dialog", { name: "Confirm deletion" })
    await expect(modal).toBeVisible()

    await modal.getByRole("button", { name: "Cancel" }).click()
    await expect(modal).toBeHidden()
  })

  test("传送的通知出现在所有内容之上", async ({ page }) => {
    await page.goto("/settings")

    await page.getByRole("button", { name: "Save" }).click()

    // 通知传送到 #notifications 容器
    const notification = page.getByRole("alert")
    await expect(notification).toBeVisible()
    await expect(notification).toContainText("Settings saved")

    // 自动关闭
    await expect(notification).toBeHidden({ timeout: 10_000 })
  })

  test("传送的下拉菜单在外部点击时关闭", 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()
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("Teleport 组件", () => {
  test("传送到 body 的模态框可见并可交互", 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("传送的通知出现在所有内容之上", 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("传送的下拉菜单在外部点击时关闭", 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()
  })
})

测试过渡和动画

适用场景:验证 Vue 的 <Transition><TransitionGroup> 组件是否正确工作——元素按预期行为出现、消失和重新排序。关注最终状态,而不是动画本身。 避免场景:测试精确的 CSS 动画关键帧——像素级验证更适合使用视觉回归测试。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("过渡动画", () => {
  test("列表项在添加时以动画形式出现", async ({ page }) => {
    await page.goto("/todo")

    await page.getByRole("textbox", { name: "New task" }).fill("Buy groceries")
    await page.getByRole("button", { name: "Add" }).click()

    // 进入过渡动画完成后,项目应可见
    // Playwright 自动等待可见性,自动处理过渡动画
    await expect(page.getByText("Buy groceries")).toBeVisible()
  })

  test("删除的项以动画形式消失并从 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("页面过渡动画在路由之间完成", async ({ page }) => {
    await page.goto("/")

    await page.getByRole("link", { name: "About" }).click()
    await page.waitForURL("/about")

    // 路由过渡动画完成后,内容应可见
    await expect(page.getByRole("heading", { name: "About" })).toBeVisible()
  })

  test("禁用动画以获得更快、更可靠的测试", async ({ page }) => {
    // 禁用所有 CSS 动画和过渡,实现确定性测试
    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()
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("过渡动画", () => {
  test("列表项在添加时以动画形式出现", 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("删除的项以动画形式消失并从 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("禁用动画以获得更快、更可靠的测试", 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()
  })
})

测试组合式 API 组件

适用场景:测试使用 Vue 3 组合式 API(<script setup>setup() 函数)构建的组件。从 Playwright 的角度来看,组合式 API 和选项式 API 组件是相同的——你测试的是渲染输出。 避免场景:你想在隔离环境中测试组合式函数——应使用 Vitest。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("组合式 API 模式", () => {
  test("计算属性响应式更新 UI", async ({ page }) => {
    await page.goto("/calculator")

    await page.getByLabel("Price").fill("100")
    await page.getByLabel("Quantity").fill("3")

    // 计算总价应响应式更新
    await expect(page.getByTestId("total")).toHaveText("$300.00")

    // 应用折扣
    await page.getByLabel("Discount (%)").fill("10")
    await expect(page.getByTestId("total")).toHaveText("$270.00")
  })

  test("侦听器在值变化时触发副作用", async ({ page }) => {
    await page.goto("/settings")

    // 更改语言会触发侦听器重新加载翻译
    await page.getByRole("combobox", { name: "Language" }).selectOption("fr")

    // 侦听器获取法语翻译——UI 更新
    await expect(page.getByRole("heading", { name: "Parametres" })).toBeVisible()
  })

  test("组合式钩子 (useSearch) 提供防抖搜索", async ({ page }) => {
    await page.goto("/products")

    const searchInput = page.getByRole("textbox", { name: "Search products" })
    await searchInput.pressSequentially("vue stickers", { delay: 50 })

    // useSearch 组合式函数进行防抖并获取结果
    await expect(page.getByRole("listitem")).toHaveCount(3)
    await expect(page.getByText("Vue Sticker Pack")).toBeVisible()
  })

  test("provide/inject 上下文在组件树中有效", async ({ page }) => {
    await page.goto("/dashboard")

    // 主题在应用根节点提供,在子组件中注入
    await page.getByRole("switch", { name: "Dark mode" }).click()

    // 所有使用 inject('theme') 的组件都应更新
    await expect(page.locator("body")).toHaveClass(/dark/)
    await expect(page.getByRole("navigation")).toHaveCSS("background-color", /rgb\(30/)
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("组合式 API 模式", () => {
  test("计算属性响应式更新 UI", async ({ page }) => {
    await page.goto("/calculator")

    await page.getByLabel("Price").fill("100")
    await page.getByLabel("Quantity").fill("3")

    await expect(page.getByTestId("total")).toHaveText("$300.00")

    await page.getByLabel("Discount (%)").fill("10")
    await expect(page.getByTestId("total")).toHaveText("$270.00")
  })

  test("侦听器在值变化时触发副作用", async ({ page }) => {
    await page.goto("/settings")

    await page.getByRole("combobox", { name: "Language" }).selectOption("fr")
    await expect(page.getByRole("heading", { name: "Parametres" })).toBeVisible()
  })

  test("组合式钩子 (useSearch) 提供防抖搜索", async ({ page }) => {
    await page.goto("/products")

    const searchInput = page.getByRole("textbox", { name: "Search products" })
    await searchInput.pressSequentially("vue stickers", { delay: 50 })

    await expect(page.getByRole("listitem")).toHaveCount(3)
    await expect(page.getByText("Vue Sticker Pack")).toBeVisible()
  })
})

测试 Nuxt.js 特定模式

适用场景:测试带有服务端渲染、自动导入、服务端路由(/server/api/)和中间件的 Nuxt 3 应用程序。 避免场景:测试没有 Nuxt 的普通 Vue SPA。

TypeScript

import { test, expect } from "@playwright/test"

test.describe("Nuxt 特定模式", () => {
  test("SSR 页面渲染服务端获取的数据", async ({ page }) => {
    await page.goto("/blog")

    // useFetch/useAsyncData 在服务端运行——内容在初始 HTML 中
    await expect(page.getByRole("article")).toHaveCount(10)
    await expect(page.getByRole("article").first()).toContainText(/\w+/)
  })

  test("Nuxt 服务端路由返回正确数据", async ({ request }) => {
    const response = await request.get("/api/products")

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

  test("Nuxt 中间件重定向未认证用户", async ({ page }) => {
    await page.goto("/admin")

    // 页面中的 definePageMeta({ middleware: 'auth' }) 触发重定向
    await expect(page).toHaveURL(/\/login/)
  })

  test("NuxtLink 启用客户端导航", async ({ page }) => {
    await page.goto("/")

    await page.evaluate(() => {
      ;(window as any).__testMarker = "no-reload"
    })

    await page.getByRole("link", { name: "Blog" }).click()
    await page.waitForURL("/blog")

    const marker = await page.evaluate(() => (window as any).__testMarker)
    expect(marker).toBe("no-reload")
  })

  test("useHead 为 SEO 设置 meta 标签", async ({ page }) => {
    await page.goto("/blog/my-post")

    const title = await page.title()
    expect(title).toContain("My Post")

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

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("Nuxt 特定模式", () => {
  test("SSR 页面渲染服务端获取的数据", async ({ page }) => {
    await page.goto("/blog")

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

  test("Nuxt 服务端路由返回正确数据", async ({ request }) => {
    const response = await request.get("/api/products")

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

  test("Nuxt 中间件重定向未认证用户", async ({ page }) => {
    await page.goto("/admin")

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

  test("NuxtLink 启用客户端导航", async ({ page }) => {
    await page.goto("/")

    await page.evaluate(() => {
      window.__testMarker = "no-reload"
    })

    await page.getByRole("link", { name: "Blog" }).click()
    await page.waitForURL("/blog")

    const marker = await page.evaluate(() => window.__testMarker)
    expect(marker).toBe("no-reload")
  })
})

框架特定技巧

Vue DevTools 与 Playwright

Vue DevTools 是一个浏览器扩展。它不会干扰 Playwright 测试,因为 Playwright 启动自己的浏览器配置文件,不加载扩展。不要在 CI 中依赖 Vue DevTools 进行调试——请改用 Playwright 的 trace 功能。

测试 v-model 双向绑定

表单输入上的 v-model 通过标准 HTML 事件工作。Playwright 的 fill()check()selectOption() 方法会自动触发正确的事件。无需特殊处理。

// 这适用于任何 v-model 输入——无需特殊的 Vue 处理
await page.getByLabel("Username").fill("janedoe")
await page.getByRole("checkbox", { name: "Remember me" }).check()
await page.getByRole("combobox", { name: "Role" }).selectOption("admin")

Vue vs Nuxt:配置差异

方面 Vue 3 (Vite) Nuxt 3
默认端口 5173 3000
开发命令 npm run dev (vite) npx nuxi dev
构建 + 预览 npm run build && npx vite preview npx nuxi build && npx nuxi preview
SSR 可选 (vue-server-renderer) 内置
API 路由 外部后端 /server/api/ 内置
环境变量 客户端使用 VITE_* 前缀 客户端使用 NUXT_PUBLIC_* 前缀,服务端使用 NUXT_*
基于文件的路由 否(使用 vue-router 配置) 是(从 /pages/ 自动生成)

组件测试:提供 Pinia 和 Router

使用 @playwright/experimental-ct-vue 时,依赖 Pinia 或 Vue Router 的组件需要提供这些依赖。配置测试包装器:

// 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 }) => {
  // 为所有组件测试安装 Pinia
  const pinia = createPinia()
  app.use(pinia)

  // 如果组件使用 <RouterLink> 或 useRoute(),则安装最小路由
  if (hooksConfig?.routes) {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: hooksConfig.routes,
    })
    app.use(router)
  }
})

在测试中处理 Vue 警告

Vue 会发出运行时警告(prop 验证、缺少组件等),这些警告可能表明真实问题:

test("渲染期间无 Vue 警告", 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("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

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

反模式

不要这样做 问题 应该这样做
page.evaluate(() => app.__vue_app__.config.globalProperties.$store) 访问 Vue 内部实现;升级时会失效;测试的是实现而非行为 断言该状态所产生的 UI
page.locator('[data-v-abc123]')(作用域样式哈希值) Vue 生成随机的作用域属性哈希值;每次构建都会变化 使用 getByRolegetByTextgetByTestId
在 E2E 测试中导入 .vue 文件 E2E 测试在 Node.js 中运行;.vue 文件需要 Vite/Webpack 编译 组件测试请使用 @playwright/experimental-ct-vue
使用 page.waitForTimeout(300) 等待过渡完成 过渡时长各不相同;任意等待很脆弱 await expect(locator).toBeVisible() 会自动等待过渡完成
通过修补 window.__pinia 来模拟 Pinia 存储 很脆弱;依赖 Pinia 内部实现;可能不会触发响应性 通过 UI 交互控制状态,或模拟存储所使用的 API 响应
通过 page.evaluate 调用组合式函数来测试它们 组合式函数依赖 Vue 的 setup 上下文;在组件外部调用会失败 通过使用该组合式函数的组件来测试,或使用 Vitest 进行单元测试
对 Vuetify 组件使用 page.locator('.v-btn') Vuetify 的类名是内部实现,在不同版本之间会变化 page.getByRole('button', { name: 'Submit' }) 无论组件库是什么都能正常工作
跳过对自定义组件的键盘导航测试 Vue 组件库通常键盘支持不完善 在下拉框、模态框、选项卡上测试 TabEnterEscapeArrowDown/Up
在 CI 中运行 Nuxt 开发服务器 开发模式包含热重载开销,构建速度较慢,存在开发警告 在 CI 中使用 npx nuxi build && npx nuxi preview

相关文档