组件测试
使用 @vue/test-utils 测试 Vue 3 组件的渲染、交互和状态。
安装与配置
安装依赖
bash
npm install -D @vue/test-utils jsdom配置文件
typescript
// tests/setup.ts
import { config } from '@vue/test-utils'
// 全局配置
config.global.stubs = {}
// vite.config.ts
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts']
}
})基本用法
挂载组件
typescript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('挂载组件', () => {
const wrapper = mount(Counter)
// 组件已挂载
expect(wrapper.exists()).toBe(true)
// 获取组件实例
expect(wrapper.vm).toBeDefined()
})访问组件内容
typescript
test('访问组件内容', () => {
const wrapper = mount(Counter)
// 获取文本内容
expect(wrapper.text()).toContain('Count: 0')
// 获取 HTML
expect(wrapper.html()).toContain('<div class="counter">')
// 查找元素
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
// 查找多个元素
const items = wrapper.findAll('li')
expect(items).toHaveLength(3)
// 检查类名
expect(wrapper.classes()).toContain('counter')
expect(wrapper.find('button').classes('primary')).toBe(true)
})传递 Props
Vue SFC
<!-- UserCard.vue -->
<template>
<div class="user-card">
<span class="name">{{ name }}</span>
<span class="age">{{ age }}</span>
<span v-if="isAdmin" class="badge">Admin</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
age: number
isAdmin?: boolean
}>()
</script>typescript
// UserCard.test.ts
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
test('传递 Props', () => {
const wrapper = mount(UserCard, {
props: {
name: 'Vue',
age: 3,
isAdmin: true
}
})
expect(wrapper.find('.name').text()).toBe('Vue')
expect(wrapper.find('.age').text()).toBe('3')
expect(wrapper.find('.badge').exists()).toBe(true)
})
test('更新 Props', async () => {
const wrapper = mount(UserCard, {
props: {
name: 'Vue',
age: 3
}
})
await wrapper.setProps({ name: 'React' })
expect(wrapper.find('.name').text()).toBe('React')
})测试插槽
默认插槽
Vue SFC
<!-- Card.vue -->
<template>
<div class="card">
<slot />
</div>
</template>typescript
test('默认插槽', () => {
const wrapper = mount(Card, {
slots: {
default: 'Card Content'
}
})
expect(wrapper.text()).toContain('Card Content')
})具名插槽
Vue SFC
<!-- Layout.vue -->
<template>
<div class="layout">
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</div>
</template>typescript
test('具名插槽', () => {
const wrapper = mount(Layout, {
slots: {
default: 'Main Content',
header: '<h1>Title</h1>',
footer: '<p>Footer</p>'
}
})
expect(wrapper.find('header').html()).toContain('<h1>Title</h1>')
expect(wrapper.find('main').text()).toBe('Main Content')
expect(wrapper.find('footer').text()).toBe('Footer')
})作用域插槽
Vue SFC
<!-- List.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="item.id" />
</li>
</ul>
</template>
<script setup lang="ts">
defineProps<{
items: Array<{ id: number; name: string }>
}>()
</script>typescript
test('作用域插槽', () => {
const wrapper = mount(List, {
props: {
items: [
{ id: 1, name: 'Vue' },
{ id: 2, name: 'React' }
]
},
slots: {
default: `
<template #default="{ item, index }">
<span>{{ index }}: {{ item.name }}</span>
</template>
`
}
})
expect(wrapper.text()).toContain('1: Vue')
expect(wrapper.text()).toContain('2: React')
})测试事件
触发 DOM 事件
Vue SFC
<!-- Button.vue -->
<template>
<button @click="handleClick">
{{ label }}
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
}>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
function handleClick(event: MouseEvent) {
emit('click', event)
}
</script>typescript
test('触发 DOM 事件', async () => {
const wrapper = mount(Button, {
props: { label: 'Click Me' }
})
const button = wrapper.find('button')
// 触发点击
await button.trigger('click')
// 检查事件是否触发
expect(wrapper.emitted()).toHaveProperty('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
test('触发键盘事件', async () => {
const wrapper = mount(InputComponent)
const input = wrapper.find('input')
// 触发 keydown 事件并传递数据
await input.trigger('keydown', { key: 'Enter' })
await input.trigger('keydown.enter')
})测试自定义事件
Vue SFC
<!-- Counter.vue -->
<template>
<button @click="increment">
{{ count }}
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const emit = defineEmits<{
change: [value: number]
}>()
function increment() {
count.value++
emit('change', count.value)
}
</script>typescript
test('自定义事件', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
// 检查事件参数
expect(wrapper.emitted('change')[0]).toEqual([1])
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('change')[1]).toEqual([2])
})表单测试
Vue SFC
<!-- LoginForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" data-testid="email" />
<input v-model="password" type="password" data-testid="password" />
<button type="submit" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const email = ref('')
const password = ref('')
const loading = ref(false)
const emit = defineEmits<{
submit: [credentials: { email: string; password: string }]
}>()
async function handleSubmit() {
loading.value = true
emit('submit', { email: email.value, password: password.value })
loading.value = false
}
</script>typescript
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'
test('表单提交', async () => {
const wrapper = mount(LoginForm)
// 填写表单
await wrapper.find('[data-testid="email"]').setValue('test@example.com')
await wrapper.find('[data-testid="password"]').setValue('password123')
// 提交表单
await wrapper.find('form').trigger('submit.prevent')
// 验证事件
expect(wrapper.emitted('submit')[0]).toEqual([{
email: 'test@example.com',
password: 'password123'
}])
})
test('表单输入', async () => {
const wrapper = mount(LoginForm)
const emailInput = wrapper.find('[data-testid="email"]')
await emailInput.setValue('test@example.com')
expect(emailInput.element.value).toBe('test@example.com')
})测试 Vue Router
安装依赖
bash
npm install -D vue-routerMock 路由
typescript
// tests/setup.ts
import { vi } from 'vitest'
// Mock vue-router
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn()
}),
useRoute: () => ({
params: { id: '1' },
query: { page: '1' },
path: '/test'
})
}))使用真实 Router
typescript
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import Nav from './Nav.vue'
const routes = [
{ path: '/', component: { template: 'Home' } },
{ path: '/about', component: { template: 'About' } }
]
test('导航组件', async () => {
const router = createRouter({
history: createWebHistory(),
routes
})
await router.push('/')
await router.isReady()
const wrapper = mount(Nav, {
global: {
plugins: [router]
}
})
expect(wrapper.html()).toContain('/')
})测试路由导航
Vue SFC
<!-- Navigation.vue -->
<template>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
</nav>
</template>typescript
import { mount, RouterLinkStub } from '@vue/test-utils'
import Navigation from './Navigation.vue'
test('导航链接', () => {
const wrapper = mount(Navigation, {
global: {
stubs: {
RouterLink: RouterLinkStub
}
}
})
const links = wrapper.findAllComponents(RouterLinkStub)
expect(links[0].props('to')).toBe('/')
expect(links[1].props('to')).toBe('/about')
})测试 Pinia
安装依赖
bash
npm install -D pinia基本配置
typescript
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach } from 'vitest'
import UserComponent from './UserComponent.vue'
beforeEach(() => {
setActivePinia(createPinia())
})测试使用 Store 的组件
Vue SFC
<!-- UserProfile.vue -->
<template>
<div>
<span v-if="user">{{ user.name }}</span>
<button @click="logout">登出</button>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const user = computed(() => userStore.user)
const logout = () => userStore.logout()
</script>typescript
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, test, expect } from 'vitest'
import UserProfile from './UserProfile.vue'
import { useUserStore } from '@/stores/user'
describe('UserProfile', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('显示用户信息', async () => {
const store = useUserStore()
store.login({ id: 1, name: 'Vue' })
const wrapper = mount(UserProfile)
expect(wrapper.text()).toContain('Vue')
})
test('登出功能', async () => {
const store = useUserStore()
store.login({ id: 1, name: 'Vue' })
const wrapper = mount(UserProfile)
await wrapper.find('button').trigger('click')
expect(store.user).toBeNull()
})
})Mock Store
typescript
import { vi } from 'vitest'
// Mock store
vi.mock('@/stores/user', () => ({
useUserStore: vi.fn(() => ({
user: { id: 1, name: 'Mocked User' },
logout: vi.fn()
}))
}))异步组件测试
测试异步数据
Vue SFC
<!-- AsyncComponent.vue -->
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error }}</div>
<div v-else>{{ data }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const data = ref<string | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
async function fetchData() {
try {
const response = await fetch('/api/data')
data.value = await response.text()
} catch (e) {
error.value = '请求失败'
} finally {
loading.value = false
}
}
onMounted(fetchData)
</script>typescript
import { mount, flushPromises } from '@vue/test-utils'
import AsyncComponent from './AsyncComponent.vue'
test('异步数据加载', async () => {
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
text: () => Promise.resolve('Hello World')
})
const wrapper = mount(AsyncComponent)
// 初始状态
expect(wrapper.text()).toContain('加载中...')
// 等待所有 Promise 完成
await flushPromises()
// 数据加载完成
expect(wrapper.text()).toContain('Hello World')
})
test('异步错误处理', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network Error'))
const wrapper = mount(AsyncComponent)
await flushPromises()
expect(wrapper.text()).toContain('错误')
})测试异步组件
typescript
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
)
test('异步组件', async () => {
const wrapper = mount(AsyncComp)
// 等待组件加载
await flushPromises()
expect(wrapper.html()).toBeTruthy()
})高级技巧
测试 Teleport
Vue SFC
<!-- Modal.vue -->
<template>
<Teleport to="body">
<div class="modal">
<slot />
</div>
</Teleport>
</template>typescript
test('Teleport 组件', () => {
const wrapper = mount(Modal, {
slots: {
default: 'Modal Content'
},
global: {
stubs: {
Teleport: {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.text()).toContain('Modal Content')
})测试 Transition
Vue SFC
<!-- FadeTransition.vue -->
<template>
<Transition name="fade">
<div v-if="show">Content</div>
</Transition>
</template>typescript
test('Transition 组件', async () => {
const wrapper = mount(FadeTransition, {
props: { show: true },
global: {
stubs: {
Transition: {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.text()).toContain('Content')
await wrapper.setProps({ show: false })
expect(wrapper.text()).not.toContain('Content')
})快照测试
typescript
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
test('按钮快照', () => {
const wrapper = mount(Button, {
props: {
label: 'Click Me',
variant: 'primary'
}
})
expect(wrapper.html()).toMatchSnapshot()
})测试 Provide/Inject
typescript
import { mount } from '@vue/test-utils'
import ChildComponent from './ChildComponent.vue'
test('Provide/Inject', () => {
const wrapper = mount(ChildComponent, {
global: {
provide: {
theme: 'dark',
user: { name: 'Vue' }
}
}
})
expect(wrapper.text()).toContain('dark')
})常见问题
Q1: 如何测试 v-model?
typescript
test('v-model 绑定', async () => {
const wrapper = mount(InputComponent)
// 设置值
await wrapper.find('input').setValue('test')
// 检查 emitted 事件
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
})Q2: 如何访问组件内部状态?
typescript
test('访问组件状态', () => {
const wrapper = mount(MyComponent)
// 访问 vm
expect(wrapper.vm.count).toBe(0)
// 访问 refs
console.log(wrapper.vm.$refs)
})Q3: 如何测试第三方组件?
使用 stubs 替换:
typescript
test('替换第三方组件', () => {
const wrapper = mount(MyComponent, {
global: {
stubs: {
'third-party-component': {
template: '<div class="stub">Stubbed</div>'
}
}
}
})
})Q4: 如何等待 DOM 更新?
typescript
import { nextTick } from 'vue'
test('等待 DOM 更新', async () => {
const wrapper = mount(MyComponent)
wrapper.vm.someMethod()
await nextTick()
// 或
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('updated')
})Q5: 如何测试全局指令?
typescript
test('测试指令', () => {
const wrapper = mount(MyComponent, {
global: {
directives: {
focus: {
mounted(el) {
el.focus()
}
}
}
}
})
})相关资源
下一步
- E2E测试 - 学习端到端测试
E2E 测试
端到端测试模拟真实用户行为,验证完整的应用流程。
概述
E2E(End-to-End)测试从用户角度验证应用,覆盖完整的业务流程,包括页面导航、表单提交、数据持久化等。
适用场景
| 场景 | 示例 |
|---|---|
| 关键业务流程 | 注册、登录、支付 |
| 跨页面操作 | 购物车结账流程 |
| 表单提交 | 用户注册、信息修改 |
| 第三方集成 | 支付网关、OAuth |
| 回归测试 | 验证核心功能稳定 |
Cypress
安装与配置
bash
# 安装 Cypress
npm install -D cypress
# 初始化 Cypress
npx cypress open项目结构
code
cypress/
├── e2e/ # 测试文件
│ ├── login.cy.ts
│ └── checkout.cy.ts
├── fixtures/ # 测试数据
│ └── user.json
├── support/ # 支持文件
│ ├── e2e.ts
│ └── commands.ts
└── cypress.config.ts # 配置文件配置文件
typescript
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
retries: {
runMode: 2,
openMode: 0
}
}
})基本用法
typescript
// cypress/e2e/login.cy.ts
describe('登录流程', () => {
beforeEach(() => {
cy.visit('/login')
})
it('用户可以成功登录', () => {
// 输入表单
cy.get('[data-testid="email"]').type('user@example.com')
cy.get('[data-testid="password"]').type('password123')
// 提交表单
cy.get('[data-testid="submit"]').click()
// 验证结果
cy.url().should('include', '/dashboard')
cy.contains('欢迎回来').should('be.visible')
})
it('显示错误提示 - 密码错误', () => {
cy.get('[data-testid="email"]').type('user@example.com')
cy.get('[data-testid="password"]').type('wrongpassword')
cy.get('[data-testid="submit"]').click()
cy.contains('密码错误').should('be.visible')
})
})常用命令
typescript
// 页面导航
cy.visit('/login')
cy.go('back')
cy.reload()
// 元素查找
cy.get('.btn') // CSS 选择器
cy.contains('提交') // 文本内容
cy.get('[data-testid="submit"]') // data-testid
cy.findByRole('button') // ARIA 角色
// 操作
cy.click()
cy.type('hello')
cy.select('option1')
cy.check()
cy.uncheck()
// 断言
cy.should('be.visible')
cy.should('have.text', 'Hello')
cy.should('have.class', 'active')
cy.should('have.value', 'input')
cy.should('exist')
cy.should('not.exist')
// 等待
cy.wait(1000)
cy.wait('@apiRequest')API Mock
typescript
// 拦截 API 请求
cy.intercept('GET', '/api/users', {
fixture: 'users.json'
}).as('getUsers')
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: { token: 'fake-token' }
}).as('login')
// 使用
cy.visit('/users')
cy.wait('@getUsers')
// 动态响应
cy.intercept('POST', '/api/login', (req) => {
const { email, password } = req.body
if (email === 'admin@example.com') {
req.reply({
statusCode: 200,
body: { role: 'admin', token: 'admin-token' }
})
} else {
req.reply({
statusCode: 401,
body: { error: 'Unauthorized' }
})
}
})测试 Fixtures
json
// cypress/fixtures/users.json
[
{ "id": 1, "name": "Vue", "email": "vue@example.com" },
{ "id": 2, "name": "React", "email": "react@example.com" }
]typescript
// 使用 fixture
cy.intercept('GET', '/api/users', { fixture: 'users.json' })
// 加载数据
cy.fixture('users').then((users) => {
cy.get('.user-list').should('have.length', users.length)
})自定义命令
typescript
// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session([email, password], () => {
cy.visit('/login')
cy.get('[data-testid="email"]').type(email)
cy.get('[data-testid="password"]').type(password)
cy.get('[data-testid="submit"]').click()
cy.url().should('include', '/dashboard')
})
})
// 类型声明
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
}
}
}typescript
// 使用自定义命令
beforeEach(() => {
cy.login('user@example.com', 'password123')
})
it('用户可以访问个人资料', () => {
cy.visit('/profile')
cy.contains('user@example.com')
})Playwright
安装与配置
bash
# 安装 Playwright
npm install -D @playwright/test
# 安装浏览器
npx playwright install
# 初始化配置
npx playwright test --init项目结构
code
tests/
├── login.spec.ts
├── checkout.spec.ts
└── fixtures/
└── test.ts
playwright.config.ts配置文件
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI
}
})基本用法
typescript
// tests/login.spec.ts
import { test, expect } from '@playwright/test'
test.describe('登录流程', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login')
})
test('用户可以成功登录', async ({ page }) => {
// 输入表单
await page.fill('[data-testid="email"]', 'user@example.com')
await page.fill('[data-testid="password"]', 'password123')
// 提交表单
await page.click('[data-testid="submit"]')
// 验证结果
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('欢迎')
})
test('显示错误提示', async ({ page }) => {
await page.fill('[data-testid="email"]', 'user@example.com')
await page.fill('[data-testid="password"]', 'wrongpassword')
await page.click('[data-testid="submit"]')
await expect(page.locator('.error')).toContainText('密码错误')
})
})常用 API
typescript
// 页面导航
await page.goto('/login')
await page.goBack()
await page.reload()
// 元素查找
await page.locator('.btn')
await page.getByRole('button')
await page.getByText('提交')
await page.getByTestId('submit')
await page.getByPlaceholder('请输入邮箱')
// 操作
await page.click('.btn')
await page.fill('input', 'hello')
await page.selectOption('select', 'option1')
await page.check('input[type="checkbox"]')
// 断言
await expect(locator).toBeVisible()
await expect(locator).toHaveText('Hello')
await expect(locator).toHaveClass(/active/)
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveTitle(/My App/)API Mock
typescript
// Mock API 响应
test('Mock API 响应', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Vue' },
{ id: 2, name: 'React' }
])
})
})
await page.goto('/users')
await expect(page.locator('.user')).toHaveCount(2)
})
// 修改请求
test('修改请求', async ({ page }) => {
await page.route('**/api/login', async (route) => {
const request = route.request()
const body = request.postDataJSON()
if (body.email === 'admin@example.com') {
await route.fulfill({
status: 200,
body: JSON.stringify({ role: 'admin' })
})
} else {
await route.continue()
}
})
})测试夹具(Fixtures)
typescript
// tests/fixtures/test.ts
import { test as base } from '@playwright/test'
type MyFixtures = {
authenticatedPage: Page
}
export const test = base.extend<MyFixtures>({
authenticatedPage: async ({ page }, use) => {
// 登录
await page.goto('/login')
await page.fill('[data-testid="email"]', 'user@example.com')
await page.fill('[data-testid="password"]', 'password123')
await page.click('[data-testid="submit"]')
await page.waitForURL('/dashboard')
await use(page)
}
})
export { expect } from '@playwright/test'typescript
// tests/profile.spec.ts
import { test, expect } from './fixtures/test'
test('用户资料页面', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile')
await expect(authenticatedPage.locator('h1')).toContainText('用户资料')
})多浏览器测试
typescript
// 指定浏览器运行
import { test, expect } from '@playwright/test'
test('在 Chromium 中测试', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', '仅 Chromium')
await page.goto('/')
// ...
})
// 或在配置中指定
test.use({ browserName: 'firefox' })视觉测试
typescript
test('视觉快照测试', async ({ page }) => {
await page.goto('/')
// 全页快照
await expect(page).toHaveScreenshot()
// 元素快照
const header = page.locator('header')
await expect(header).toHaveScreenshot()
// 允许差异
await expect(page).toHaveScreenshot({ maxDiffPixels: 100 })
})Cypress vs Playwright 对比
| 特性 | Cypress | Playwright |
|---|---|---|
| 学习曲线 | 低 | 中 |
| 调试体验 | 优秀 | 良好 |
| 跨浏览器 | Chromium 系 | 全支持 |
| 执行速度 | 快 | 快 |
| 并行测试 | 需付费 | 原生支持 |
| 语言支持 | JS/TS | JS/TS/Python/.NET/Java |
| 测试运行器 | 内置 UI | CLI + 可选 UI |
| 自动等待 | 支持 | 支持 |
| 网络控制 | 强大 | 强大 |
| 视觉测试 | 插件支持 | 原生支持 |
| 移动端模拟 | 有限 | 完善 |
选择建议
code
┌─────────────────────────────────────────────────────────────┐
│ 框架选择指南 │
├─────────────────────────────────────────────────────────────┤
│ Cypress 适合: │
│ • 快速上手,学习成本低 │
│ • 优秀的调试体验 │
│ • 主要面向 Chromium 浏览器 │
│ • 小到中型项目 │
├─────────────────────────────────────────────────────────────┤
│ Playwright 适合: │
│ • 需要跨浏览器测试 │
│ • 大型项目和团队 │
│ • 需要并行测试(免费) │
│ • 多语言支持需求 │
└─────────────────────────────────────────────────────────────┘最佳实践
使用 data-testid
Vue SFC
<template>
<form>
<input data-testid="email-input" type="email" />
<input data-testid="password-input" type="password" />
<button data-testid="submit-button">登录</button>
</form>
</template>typescript
// Cypress
cy.get('[data-testid="email-input"]').type('user@example.com')
// Playwright
await page.getByTestId('email-input').fill('user@example.com')测试隔离
typescript
// Cypress
describe('用户管理', () => {
beforeEach(() => {
// 重置数据库或使用测试数据库
cy.resetDb()
// 清除 cookies 和 local storage
cy.clearCookies()
cy.clearLocalStorage()
})
})
// Playwright
test.describe('用户管理', () => {
test.use({ storageState: { cookies: [], origins: [] } })
test.beforeEach(async ({ page }) => {
await page.context().clearCookies()
})
})Page Object Model
typescript
// cypress/support/pages/LoginPage.ts
class LoginPage {
visit() {
cy.visit('/login')
}
fillEmail(email: string) {
cy.get('[data-testid="email"]').type(email)
}
fillPassword(password: string) {
cy.get('[data-testid="password"]').type(password)
}
submit() {
cy.get('[data-testid="submit"]').click()
}
login(email: string, password: string) {
this.visit()
this.fillEmail(email)
this.fillPassword(password)
this.submit()
}
}
export default new LoginPage()typescript
// 使用
import loginPage from '../support/pages/LoginPage'
it('用户登录', () => {
loginPage.login('user@example.com', 'password123')
cy.url().should('include', '/dashboard')
})处理动态内容
typescript
// Cypress - 自动重试
cy.get('.loading').should('not.exist')
cy.get('.data').should('be.visible')
// Playwright - 自动等待
await page.waitForSelector('.loading', { state: 'hidden' })
await expect(page.locator('.data')).toBeVisible()测试配置
typescript
// 环境变量
// cypress.config.ts
export default defineConfig({
e2e: {
env: {
apiUrl: 'https://api.example.com',
user: {
email: 'test@example.com',
password: 'test123'
}
}
}
})
// 使用
Cypress.env('apiUrl')
Cypress.env('user').emailCI/CD 集成
GitHub Actions
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
start: npm run dev
wait-on: 'http://localhost:5173'
- name: Upload screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30常见问题
Q1: E2E 测试太慢怎么办?
解决方案:
- 使用并行测试(Playwright 原生支持)
- 只在 CI 运行完整测试套件
- 本地运行单个测试文件
- 使用 API 直接设置测试数据
Q2: 测试不稳定(Flaky Tests)如何处理?
解决方案:
typescript
// Cypress - 增加重试
it('不稳定测试', { retries: 3 }, () => {
// ...
})
// Playwright - 配置重试
export default defineConfig({
retries: 2
})Q3: 如何测试文件上传?
typescript
// Cypress
cy.get('input[type="file"]').selectFile('cypress/fixtures/file.pdf')
// Playwright
await page.setInputFiles('input[type="file"]', 'tests/fixtures/file.pdf')Q4: 如何测试新窗口/标签页?
typescript
// Cypress - 需要特殊处理
cy.window().then((win) => {
cy.stub(win, 'open').as('windowOpen')
})
cy.get('.open-link').click()
cy.get('@windowOpen').should('be.called')
// Playwright
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('.open-link')
])
await expect(newPage).toHaveURL(/new-page/)Q5: 如何处理身份认证?
typescript
// Cypress - 使用 session
cy.session('login', () => {
cy.visit('/login')
cy.get('[data-testid="email"]').type('user@example.com')
cy.get('[data-testid="password"]').type('password')
cy.get('[data-testid="submit"]').click()
})
// Playwright - 保存认证状态
test('登录并保存状态', async ({ page, context }) => {
await page.goto('/login')
await page.fill('[data-testid="email"]', 'user@example.com')
await page.fill('[data-testid="password"]', 'password')
await page.click('[data-testid="submit"]')
await context.storageState({ path: 'auth.json' })
})
// 使用保存的状态
test.use({ storageState: 'auth.json' })相关资源
下一步
- 风格指南 - 学习最佳实践