{T}

组件测试

使用 @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-router

Mock 路由

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(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 对比

特性CypressPlaywright
学习曲线
调试体验优秀良好
跨浏览器Chromium 系全支持
执行速度
并行测试需付费原生支持
语言支持JS/TSJS/TS/Python/.NET/Java
测试运行器内置 UICLI + 可选 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').email

CI/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' })

相关资源

下一步