39 KiB
使用 Playwright 测试 Vue 应用
适用场景:测试 Vue 3 应用程序,包括组合式 API 组件、Pinia 存储、Vue Router 导航、Nuxt.js 应用、Teleport 传送门以及过渡动画。涵盖端到端测试和实验性的
@playwright/experimental-ct-vue组件测试。 前置要求:core/configuration.md、core/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 生成随机的作用域属性哈希值;每次构建都会变化 | 使用 getByRole、getByText、getByTestId |
在 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 组件库通常键盘支持不完善 | 在下拉框、模态框、选项卡上测试 Tab、Enter、Escape、ArrowDown/Up |
| 在 CI 中运行 Nuxt 开发服务器 | 开发模式包含热重载开销,构建速度较慢,存在开发警告 | 在 CI 中使用 npx nuxi build && npx nuxi preview |
相关文档
- core/locators.md——适用于任何 Vue 组件库(Vuetify、PrimeVue、Quasar)的定位器策略
- core/assertions-and-waiting.md——适用于 Vue 响应性的自动等待断言
- core/component-testing.md——深入的组件测试模式
- core/forms-and-validation.md——VeeValidate 和 FormKit 的表单测试模式
- core/accessibility.md——Vue 组件库的可访问性测试
- core/test-architecture.md——何时使用 E2E 测试、组件测试与单元测试
- core/nextjs.md——对比:Nuxt 与 Next.js 的测试模式