组件测试
使用 @vue/test-utils 和 Vue Testing Library 测试 Vue 2 组件。
概述
组件测试是 Vue 应用测试的核心环节,主要验证:
- 渲染输出:组件是否正确渲染预期内容
- 用户交互:点击、输入等事件是否正确响应
- 状态管理:组件内部状态变化是否符合预期
- Props/Events:组件间通信是否正确
一、安装与配置
安装依赖
bash
# Vue Test Utils
npm install --save-dev @vue/test-utils@1 vue-template-compiler
# Jest + Vue Jest
npm install --save-dev jest @vue/vue2-jest babel-jest
# 测试工具
npm install --save-dev jsdom jsdom-globalJest 配置
javascript
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
moduleFileExtensions: ['js', 'json', 'vue'],
transform: {
'^.+\\.vue$': '@vue/vue2-jest',
'^.+\\.js$': 'babel-jest'
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.js']
}测试环境设置
javascript
// tests/setup.js
import Vue from 'vue'
import { config } from '@vue/test-utils'
// 全局配置
Vue.config.productionTip = false
Vue.config.devtools = false
// Mock 全局组件
config.stubs = {
RouterLink: true,
RouterView: true
}
// 全局 Mock
config.mocks = {
$route: {},
$router: {}
}二、挂载组件
mount 与 shallowMount
| 方法 | 说明 | 适用场景 |
|---|---|---|
mount | 完整挂载,渲染子组件 | 集成测试 |
shallowMount | 浅挂载,替换子组件为存根 | 单元测试 |
javascript
import { mount, shallowMount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'
// 完整挂载
const wrapper = mount(MyComponent, {
propsData: { title: 'Hello' }
})
// 浅挂载(推荐用于单元测试)
const wrapper = shallowMount(MyComponent, {
propsData: { title: 'Hello' }
})mount 与 shallowMount 的底层实现
javascript
// mount: 完整渲染子组件
// 内部调用 createInstance → 子组件完整执行 init → render → mount 全流程
const wrapper = mount(ParentComponent)
// wrapper.find(ChildComponent).exists() → true
// shallowMount: 子组件打桩(stub)
// 内部调用 createInstance → 子组件 VNode 替换为 stub 组件
// stub 组件只渲染占位元素,不执行子组件内部逻辑
const wrapper = shallowMount(ParentComponent)
// wrapper.find(ChildComponent).exists() → false(被 stub 替换)
// 但可以通过 wrapper.find('child-component-stub') 找到占位组件选择指南:shallowMount 适用于单元测试,确保只测试当前组件逻辑;mount 适用于集成测试,需要验证组件间交互。
挂载选项
javascript
const wrapper = mount(MyComponent, {
// Props
propsData: {
title: 'Test Title',
items: ['a', 'b', 'c']
},
// 插槽
slots: {
default: '<span>Slot Content</span>',
footer: '<p>Footer</p>'
},
// 作用域插槽
scopedSlots: {
default: '<template #default="{ item }">{{ item.name }}</template>'
},
// 全局插件
mocks: {
$route: { path: '/test' },
$store: { state: { user: {} } }
},
// 存根组件
stubs: {
RouterLink: true,
ChildComponent: { template: '<div class="child-stub"></div>' }
},
// 局部组件
components: {
LocalComponent
},
// 指令
directives: {
myDirective: { /* directive definition */ }
},
// 事件监听器
listeners: {
click: jest.fn()
},
// 提供的属性
provide: {
theme: 'dark'
}
// 挂载到特定容器
attachTo: document.body,
// 挂载选项
attachToDocument: true, // 已弃用,使用 attachTo
sync: false // 禁用同步更新
})三、测试渲染输出
选择器查询
javascript
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'
const wrapper = mount(Button, {
propsData: { text: 'Click Me' }
})
// DOM 选择器
wrapper.find('button') // CSS 选择器
wrapper.find('.btn-primary') // 类选择器
wrapper.find('#submit-btn') // ID 选择器
wrapper.find('[data-testid="btn"]') // 属性选择器
// Vue 组件选择器
wrapper.findComponent(ChildComponent) // 查找子组件
wrapper.findComponent({ name: 'ChildComponent' })
// 查询所有
wrapper.findAll('li')
wrapper.findAllComponents(ChildComponent)
// 检查是否存在
wrapper.find('.not-exist').exists() // false
wrapper.find('.btn-primary').exists() // true访问元素属性
javascript
// 获取 DOM 元素
const button = wrapper.find('button')
const element = button.element
// 元素属性
element.value // 输入值
element.checked // 复选框状态
element.disabled // 禁用状态
element.textContent // 文本内容
element.innerHTML // HTML 内容
element.getAttribute('class') // 属性值
// Wrapper 方法
wrapper.text() // 文本内容
wrapper.html() // HTML 字符串
wrapper.classes() // 类名数组 ['btn', 'btn-primary']
wrapper.classes('active') // 是否有该类名
wrapper.attributes() // 属性对象
wrapper.attributes('disabled') // 特定属性值
wrapper.props() // 组件 props
wrapper.props('title') // 特定 prop 值测试示例
javascript
// src/components/Alert.vue
<template>
<div
:class="['alert', `alert-${type}`]"
role="alert"
data-testid="alert"
>
<strong v-if="title">{{ title }}</strong>
<span>{{ message }}</span>
<button
v-if="dismissible"
@click="$emit('dismiss')"
class="close"
>
×
</button>
</div>
</template>
<script>
export default {
name: 'Alert',
props: {
type: {
type: String,
default: 'info',
validator: v => ['info', 'success', 'warning', 'danger'].includes(v)
},
title: String,
message: {
type: String,
required: true
},
dismissible: Boolean
}
}
</script>
// tests/components/Alert.spec.js
import { mount } from '@vue/test-utils'
import Alert from '@/components/Alert.vue'
describe('Alert.vue', () => {
it('应该渲染消息内容', () => {
const wrapper = mount(Alert, {
propsData: { message: 'Test Message' }
})
expect(wrapper.text()).toContain('Test Message')
expect(wrapper.find('[data-testid="alert"]').exists()).toBe(true)
})
it('应该根据 type 设置正确的类名', () => {
const wrapper = mount(Alert, {
propsData: {
message: 'Warning!',
type: 'warning'
}
})
expect(wrapper.classes()).toContain('alert')
expect(wrapper.classes()).toContain('alert-warning')
})
it('有 title 时应该渲染标题', () => {
const wrapper = mount(Alert, {
propsData: {
message: 'Content',
title: 'Title'
}
})
expect(wrapper.find('strong').text()).toBe('Title')
})
it('dismissible 为 true 时应该显示关闭按钮', () => {
const wrapper = mount(Alert, {
propsData: {
message: 'Content',
dismissible: true
}
})
expect(wrapper.find('button.close').exists()).toBe(true)
})
it('点击关闭按钮应该触发 dismiss 事件', async () => {
const wrapper = mount(Alert, {
propsData: {
message: 'Content',
dismissible: true
}
})
await wrapper.find('button.close').trigger('click')
expect(wrapper.emitted().dismiss).toBeTruthy()
expect(wrapper.emitted().dismiss.length).toBe(1)
})
})四、测试用户交互
触发事件
javascript
const wrapper = mount(Component)
// DOM 事件
await wrapper.trigger('click')
await wrapper.trigger('click', { button: 0 }) // 左键
await wrapper.trigger('dblclick')
await wrapper.trigger('mouseenter')
await wrapper.trigger('mouseleave')
// 键盘事件
await wrapper.trigger('keydown')
await wrapper.trigger('keydown.enter')
await wrapper.trigger('keydown.esc')
await wrapper.trigger('keydown', { key: 'Enter', code: 'Enter' })
// 表单事件
await wrapper.trigger('focus')
await wrapper.trigger('blur')
await wrapper.trigger('change')
await wrapper.trigger('input')表单交互示例
javascript
// src/components/LoginForm.vue
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
data-testid="email-input"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
data-testid="password-input"
/>
</div>
<button
type="submit"
:disabled="!isValid"
data-testid="submit-btn"
>
Login
</button>
</form>
</template>
<script>
export default {
name: 'LoginForm',
data() {
return {
form: {
email: '',
password: ''
}
}
},
computed: {
isValid() {
return this.form.email && this.form.password.length >= 6
}
},
methods: {
handleSubmit() {
if (this.isValid) {
this.$emit('submit', this.form)
}
}
}
}
</script>
// tests/components/LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from '@/components/LoginForm.vue'
describe('LoginForm.vue', () => {
let wrapper
beforeEach(() => {
wrapper = mount(LoginForm)
})
it('输入字段应该正确绑定 v-model', async () => {
const emailInput = wrapper.find('[data-testid="email-input"]')
const passwordInput = wrapper.find('[data-testid="password-input"]')
await emailInput.setValue('test@example.com')
await passwordInput.setValue('password123')
expect(wrapper.vm.form.email).toBe('test@example.com')
expect(wrapper.vm.form.password).toBe('password123')
})
it('表单无效时提交按钮应该禁用', () => {
const submitBtn = wrapper.find('[data-testid="submit-btn"]')
expect(submitBtn.element.disabled).toBe(true)
})
it('表单有效时提交按钮应该启用', async () => {
await wrapper.find('[data-testid="email-input"]').setValue('test@example.com')
await wrapper.find('[data-testid="password-input"]').setValue('password123')
const submitBtn = wrapper.find('[data-testid="submit-btn"]')
expect(submitBtn.element.disabled).toBe(false)
})
it('提交时应该发出 submit 事件', async () => {
await wrapper.find('[data-testid="email-input"]').setValue('test@example.com')
await wrapper.find('[data-testid="password-input"]').setValue('password123')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted().submit).toBeTruthy()
expect(wrapper.emitted().submit[0]).toEqual([{
email: 'test@example.com',
password: 'password123'
}])
})
})测试组件方法
javascript
const wrapper = mount(Component)
// 直接调用方法
wrapper.vm.someMethod(arg1, arg2)
// 设置 data
await wrapper.setData({ count: 10 })
await wrapper.setData({ user: { name: 'John' } })
// 设置 props
await wrapper.setProps({ title: 'New Title' })
// 设置计算属性(通过设置依赖的 data)
await wrapper.setData({ items: [1, 2, 3] })五、测试异步行为
Vue.nextTick
javascript
import { mount } from '@vue/test-utils'
it('应该等待 Vue 更新', async () => {
const wrapper = mount(AsyncComponent)
wrapper.vm.fetchData()
// 等待 DOM 更新
await wrapper.vm.$nextTick()
// 或使用 Vue.nextTick
await Vue.nextTick()
expect(wrapper.text()).toContain('Loaded')
})测试 watch 和 computed
javascript
// src/components/SearchInput.vue
export default {
data() {
return {
query: ''
}
},
watch: {
query(newVal) {
this.$emit('search', newVal)
}
},
computed: {
hasQuery() {
return this.query.length > 0
}
}
}
// tests/components/SearchInput.spec.js
it('query 变化时应该发出 search 事件', async () => {
const wrapper = mount(SearchInput)
await wrapper.setData({ query: 'test' })
expect(wrapper.emitted().search).toBeTruthy()
expect(wrapper.emitted().search[0]).toEqual(['test'])
})
it('hasQuery 计算属性应该正确计算', async () => {
const wrapper = mount(SearchInput)
expect(wrapper.vm.hasQuery).toBe(false)
await wrapper.setData({ query: 'test' })
expect(wrapper.vm.hasQuery).toBe(true)
})测试异步方法
javascript
// src/components/DataLoader.vue
export default {
data() {
return {
loading: false,
data: null,
error: null
}
},
methods: {
async loadData(id) {
this.loading = true
try {
const response = await api.fetch(id)
this.data = response.data
this.$emit('loaded', this.data)
} catch (error) {
this.error = error.message
this.$emit('error', error)
} finally {
this.loading = false
}
}
}
}
// tests/components/DataLoader.spec.js
import { mount } from '@vue/test-utils'
import api from '@/api'
jest.mock('@/api')
it('loadData 应该正确处理成功响应', async () => {
const mockData = { id: 1, name: 'Test' }
api.fetch.mockResolvedValue({ data: mockData })
const wrapper = mount(DataLoader)
await wrapper.vm.loadData(1)
expect(wrapper.vm.loading).toBe(false)
expect(wrapper.vm.data).toEqual(mockData)
expect(wrapper.emitted().loaded[0]).toEqual([mockData])
})
it('loadData 应该正确处理错误', async () => {
const error = new Error('Network Error')
api.fetch.mockRejectedValue(error)
const wrapper = mount(DataLoader)
await wrapper.vm.loadData(1)
expect(wrapper.vm.loading).toBe(false)
expect(wrapper.vm.error).toBe('Network Error')
expect(wrapper.emitted().error[0]).toEqual([error])
})使用 flush-promises
bash
npm install --save-dev flush-promisesjavascript
import flushPromises from 'flush-promises'
it('应该等待所有 Promise 完成', async () => {
const wrapper = mount(AsyncComponent)
wrapper.vm.fetchData()
await flushPromises()
expect(wrapper.text()).toContain('Loaded')
})六、测试 Vuex 集成
创建测试 Store
javascript
import Vuex from 'vuex'
import { createLocalVue, mount } from '@vue/test-utils'
import storeConfig from '@/store'
import Component from '@/components/Component.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('Vuex 集成测试', () => {
let store
let wrapper
beforeEach(() => {
store = new Vuex.Store(storeConfig)
wrapper = mount(Component, {
store,
localVue
})
})
})简化 Mock Store
javascript
// tests/utils/mock-store.js
export function createMockStore(state = {}, actions = {}, getters = {}) {
return {
state,
getters,
dispatch: jest.fn(),
commit: jest.fn(),
subscribe: jest.fn(),
...actions
}
}
// 使用
const wrapper = mount(Component, {
mocks: {
$store: createMockStore(
{ count: 10 },
{},
{ doubleCount: 20 }
)
}
})测试示例
javascript
// src/components/Counter.vue
<template>
<div>
<span data-testid="count">{{ count }}</span>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex'
export default {
computed: {
...mapState(['count'])
},
methods: {
...mapActions(['increment', 'decrement'])
}
}
</script>
// tests/components/Counter.spec.js
import Vuex from 'vuex'
import { createLocalVue, mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('Counter.vue', () => {
let store
let actions
let wrapper
beforeEach(() => {
actions = {
increment: jest.fn(),
decrement: jest.fn()
}
store = new Vuex.Store({
state: { count: 5 },
actions
})
wrapper = mount(Counter, {
store,
localVue
})
})
it('应该显示 store 中的 count', () => {
expect(wrapper.find('[data-testid="count"]').text()).toBe('5')
})
it('点击 + 按钮应该调用 increment action', async () => {
await wrapper.findAll('button')[0].trigger('click')
expect(actions.increment).toHaveBeenCalled()
})
it('点击 - 按钮应该调用 decrement action', async () => {
await wrapper.findAll('button')[1].trigger('click')
expect(actions.decrement).toHaveBeenCalled()
})
})七、测试 Vue Router
Mock Router
javascript
import { mount } from '@vue/test-utils'
const $route = {
path: '/user/123',
params: { id: '123' },
query: { tab: 'profile' }
}
const wrapper = mount(Component, {
mocks: {
$route
}
})Mock $router
javascript
const $router = {
push: jest.fn(),
replace: jest.fn(),
go: jest.fn()
}
const wrapper = mount(Component, {
mocks: {
$router
}
})
// 验证
expect($router.push).toHaveBeenCalledWith('/home')使用真实的 Vue Router
javascript
import VueRouter from 'vue-router'
import { createLocalVue, mount } from '@vue/test-utils'
const localVue = createLocalVue()
localVue.use(VueRouter)
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About }
]
const router = new VueRouter({ routes })
const wrapper = mount(Component, {
router,
localVue
})八、快照测试
基本快照
javascript
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'
it('应该匹配快照', () => {
const wrapper = mount(Button, {
propsData: {
text: 'Click Me',
type: 'primary'
}
})
expect(wrapper.html()).toMatchSnapshot()
})更新快照
bash
# 更新所有快照
jest --updateSnapshot
# 更新特定测试的快照
jest --updateSnapshot --testNamePattern="Button"内联快照
javascript
it('应该匹配内联快照', () => {
const wrapper = mount(Button, {
propsData: { text: 'Submit' }
})
expect(wrapper.html()).toMatchInlineSnapshot()
})九、最佳实践
1. 使用 data-testid
Vue SFC
<template>
<!-- 推荐:使用 data-testid -->
<button data-testid="submit-btn">Submit</button>
<!-- 不推荐:依赖 CSS 类名 -->
<button class="btn btn-primary">Submit</button>
</template>javascript
// 查询元素
const btn = wrapper.find('[data-testid="submit-btn"]')2. 隔离测试
javascript
// 每个测试都应该独立
beforeEach(() => {
jest.clearAllMocks()
wrapper = mount(Component)
})
afterEach(() => {
wrapper.destroy()
})3. 测试行为而非实现
javascript
// ❌ 不推荐:测试实现细节
it('应该设置 loading 为 true', async () => {
await wrapper.vm.loadData()
expect(wrapper.vm.loading).toBe(false)
})
// ✅ 推荐:测试可见行为
it('应该显示加载状态', async () => {
wrapper.vm.loadData()
expect(wrapper.find('.loading').exists()).toBe(true)
await flushPromises()
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.text()).toContain('Data loaded')
})4. 合理使用 shallowMount
javascript
// 单元测试:使用 shallowMount
const wrapper = shallowMount(ParentComponent)
// 集成测试:使用 mount
const wrapper = mount(ParentComponent)十、常见问题
Q1: 找不到组件怎么办?
javascript
// 确保正确导入组件
import MyComponent from '@/components/MyComponent.vue'
// 检查组件名称
wrapper.findComponent({ name: 'MyComponent' })Q2: 如何测试 transition?
javascript
// 在测试中禁用 transition
const wrapper = mount(Component, {
stubs: {
transition: false // 或 true
}
})Q3: 如何测试 teleport?
javascript
// Vue 2 没有 teleport,但可以测试类似逻辑
const wrapper = mount(PortalComponent, {
attachTo: document.body
})
// 测试完成后清理
afterEach(() => {
wrapper.destroy()
})相关资源
E2E 测试
一、Cypress
安装与配置
bash
# 安装 Cypress
npm install --save-dev cypress
# Vue CLI 插件方式
vue add e2e-cypress
# 初始化
npx cypress open项目结构
code
project/
├── cypress/
│ ├── fixtures/ # 测试数据
│ │ └── users.json
│ ├── integration/ # 测试文件
│ │ ├── login.spec.js
│ │ └── dashboard.spec.js
│ ├── plugins/ # 插件配置
│ │ └── index.js
│ ├── support/ # 支持文件
│ │ ├── commands.js # 自定义命令
│ │ └── index.js
│ └── screenshots/ # 截图
├── cypress.json # Cypress 配置
└── package.json配置文件
javascript
// cypress.json
{
"baseUrl": "http://localhost:8080",
"viewportWidth": 1280,
"viewportHeight": 720,
"video": true,
"videoCompression": 32,
"screenshotOnRunFailure": true,
"defaultCommandTimeout": 10000,
"requestTimeout": 10000,
"responseTimeout": 30000,
"fixturesFolder": "cypress/fixtures",
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.js",
"pluginsFile": "cypress/plugins/index.js",
"testFiles": "**/*.spec.js",
"ignoreTestFiles": "*.skip.js",
"env": {
"apiUrl": "http://localhost:3000/api",
"userEmail": "test@example.com",
"userPassword": "password123"
}
}基本语法
javascript
// cypress/integration/example.spec.js
describe('测试套件名称', () => {
beforeEach(() => {
// 每个测试前执行
cy.visit('/')
})
it('测试用例名称', () => {
// 断言
cy.get('h1').should('contain', 'Welcome')
})
afterEach(() => {
// 每个测试后执行
})
})选择器
javascript
// CSS 选择器
cy.get('button')
cy.get('.btn-primary')
cy.get('#submit')
cy.get('[data-testid="login-btn"]')
// 文本内容
cy.contains('Submit')
cy.contains('button', 'Submit')
// 表单元素
cy.get('input[name="email"]')
cy.get('input[type="password"]')
// 组合选择器
cy.get('form').find('button')
cy.get('ul').children('li')
cy.get('li').first()
cy.get('li').last()
cy.get('li').eq(2)
cy.get('li').filter('.active')
// 关系选择器
cy.get('input').parent()
cy.get('input').parents('form')
cy.get('li').siblings()
cy.get('p').next()
cy.get('p').prev()操作命令
javascript
// 页面导航
cy.visit('/login')
cy.visit({ url: '/search', qs: { q: 'vue' } })
cy.go('back')
cy.go('forward')
cy.reload()
// 点击
cy.get('button').click()
cy.get('button').click({ force: true })
cy.get('button').click({ multiple: true })
cy.get('button').dblclick()
cy.get('button').rightclick()
// 输入
cy.get('input').type('Hello')
cy.get('input').type('Hello{enter}')
cy.get('input').type('{selectall}{backspace}')
cy.get('input').clear()
cy.get('input').clear().type('New value')
// 选择
cy.get('select').select('option-value')
cy.get('select').select(['value1', 'value2'])
cy.get('input[type="checkbox"]').check()
cy.get('input[type="radio"]').check('value')
cy.get('input[type="checkbox"]').uncheck()
// 文件上传
cy.get('input[type="file"]').selectFile('cypress/fixtures/file.pdf')
cy.get('input[type="file"]').selectFile({
contents: 'cypress/fixtures/file.pdf',
fileName: 'document.pdf',
mimeType: 'application/pdf'
})
// 滚动
cy.get('.element').scrollIntoView()
cy.scrollTo(0, 500)
cy.scrollTo('bottom')
// 等待
cy.wait(1000)
cy.wait('@apiRequest')
// 焦点
cy.get('input').focus()
cy.get('input').blur()
// 触发事件
cy.get('button').trigger('mouseover')
cy.get('input').trigger('keydown', { key: 'Enter' })断言
javascript
// 基本断言
cy.get('h1').should('exist')
cy.get('h1').should('not.exist')
cy.get('h1').should('be.visible')
cy.get('h1').should('be.hidden')
cy.get('input').should('be.disabled')
cy.get('input').should('be.enabled')
cy.get('input').should('be.checked')
cy.get('input').should('be.focused')
// 内容断言
cy.get('h1').should('contain', 'Welcome')
cy.get('h1').should('have.text', 'Welcome')
cy.get('h1').should('have.html', '<span>Welcome</span>')
cy.get('input').should('have.value', 'test@example.com')
cy.get('input').should('be.empty')
// 属性断言
cy.get('a').should('have.attr', 'href', '/home')
cy.get('input').should('have.attr', 'placeholder')
cy.get('button').should('have.class', 'active')
cy.get('button').should('not.have.class', 'disabled')
// 数量断言
cy.get('li').should('have.length', 5)
// CSS 断言
cy.get('div').should('have.css', 'color', 'rgb(0, 0, 0)')
cy.get('div').should('have.css', 'display', 'block')
// 链式断言
cy.get('button')
.should('be.visible')
.and('have.class', 'btn-primary')
.and('contain', 'Submit')
// 自定义断言
cy.get('h1').should(($h1) => {
expect($h1).to.have.length(1)
expect($h1.text()).to.match(/Welcome/)
})网络请求
javascript
// 拦截请求
cy.intercept('GET', '/api/users').as('getUsers')
cy.intercept('POST', '/api/users').as('createUser')
cy.intercept('DELETE', '/api/users/*').as('deleteUser')
// 等待请求完成
cy.wait('@getUsers')
cy.wait('@createUser').its('response.statusCode').should('eq', 201)
// Mock 响应
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
})
// 动态 Mock
cy.intercept('GET', '/api/users', (req) => {
req.reply({
statusCode: 200,
body: mockUsers
})
})
// 修改请求
cy.intercept('POST', '/api/users', (req) => {
req.body.createdAt = new Date().toISOString()
}).as('createUser')
// 模拟错误
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { error: 'Internal Server Error' }
})
// 延迟响应
cy.intercept('GET', '/api/users', {
delay: 2000,
body: mockUsers
})测试示例:登录流程
javascript
// cypress/integration/auth/login.spec.js
describe('登录功能', () => {
beforeEach(() => {
cy.visit('/login')
})
it('应该显示登录表单', () => {
cy.get('[data-testid="login-form"]').should('be.visible')
cy.get('[data-testid="email-input"]').should('exist')
cy.get('[data-testid="password-input"]').should('exist')
cy.get('[data-testid="login-btn"]').should('exist')
})
it('输入无效邮箱应该显示错误提示', () => {
cy.get('[data-testid="email-input"]').type('invalid-email')
cy.get('[data-testid="login-btn"]').click()
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', '请输入有效的邮箱地址')
})
it('成功登录应该跳转到首页', () => {
// Mock 登录请求
cy.intercept('POST', '/api/auth/login', {
statusCode: 200,
body: {
token: 'mock-token',
user: { id: 1, name: 'Test User' }
}
}).as('login')
cy.get('[data-testid="email-input"]').type('test@example.com')
cy.get('[data-testid="password-input"]').type('password123')
cy.get('[data-testid="login-btn"]').click()
cy.wait('@login')
cy.url().should('include', '/dashboard')
cy.get('[data-testid="welcome-message"]')
.should('contain', 'Welcome, Test User')
})
it('登录失败应该显示错误信息', () => {
cy.intercept('POST', '/api/auth/login', {
statusCode: 401,
body: { error: 'Invalid credentials' }
}).as('login')
cy.get('[data-testid="email-input"]').type('test@example.com')
cy.get('[data-testid="password-input"]').type('wrongpassword')
cy.get('[data-testid="login-btn"]').click()
cy.wait('@login')
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', '用户名或密码错误')
})
})测试示例:购物车
javascript
// cypress/integration/shop/cart.spec.js
describe('购物车功能', () => {
beforeEach(() => {
cy.intercept('GET', '/api/products', { fixture: 'products.json' })
cy.visit('/products')
})
it('应该能够添加商品到购物车', () => {
cy.get('[data-testid="product-item"]').first().as('firstProduct')
cy.get('@firstProduct')
.find('[data-testid="add-to-cart-btn"]')
.click()
cy.get('[data-testid="cart-count"]')
.should('contain', '1')
cy.get('[data-testid="cart-icon"]').click()
cy.get('[data-testid="cart-item"]').should('have.length', 1)
})
it('应该能够修改商品数量', () => {
// 先添加商品
cy.get('[data-testid="add-to-cart-btn"]').first().click()
cy.get('[data-testid="cart-icon"]').click()
// 增加数量
cy.get('[data-testid="quantity-increase"]').click()
cy.get('[data-testid="item-quantity"]').should('contain', '2')
// 减少数量
cy.get('[data-testid="quantity-decrease"]').click()
cy.get('[data-testid="item-quantity"]').should('contain', '1')
})
it('应该能够删除购物车商品', () => {
cy.get('[data-testid="add-to-cart-btn"]').first().click()
cy.get('[data-testid="cart-icon"]').click()
cy.get('[data-testid="remove-item-btn"]').click()
cy.get('[data-testid="cart-empty-message"]')
.should('be.visible')
.and('contain', '购物车为空')
})
it('应该能够完成结账流程', () => {
cy.intercept('POST', '/api/orders', {
statusCode: 201,
body: { orderId: 'ORD-001' }
}).as('createOrder')
// 添加商品
cy.get('[data-testid="add-to-cart-btn"]').first().click()
// 进入购物车
cy.get('[data-testid="cart-icon"]').click()
cy.get('[data-testid="checkout-btn"]').click()
// 填写表单
cy.get('[data-testid="address-input"]').type('123 Main St')
cy.get('[data-testid="city-input"]').type('New York')
cy.get('[data-testid="phone-input"]').type('1234567890')
// 提交订单
cy.get('[data-testid="place-order-btn"]').click()
cy.wait('@createOrder')
cy.url().should('include', '/order/success')
cy.get('[data-testid="order-id"]').should('contain', 'ORD-001')
})
})自定义命令
javascript
// cypress/support/commands.js
// 登录命令
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.intercept('POST', '/api/auth/login').as('login')
cy.visit('/login')
cy.get('[data-testid="email-input"]').type(email)
cy.get('[data-testid="password-input"]').type(password)
cy.get('[data-testid="login-btn"]').click()
cy.wait('@login')
cy.getCookie('auth_token').should('exist')
})
})
// 等待元素
Cypress.Commands.add('waitForElement', (selector, timeout = 10000) => {
cy.get(selector, { timeout }).should('be.visible')
})
// 数据库操作
Cypress.Commands.add('resetDatabase', () => {
cy.request('POST', '/api/test/reset-database')
})
// 使用
cy.login('test@example.com', 'password123')
cy.waitForElement('[data-testid="dashboard"]')二、Nightwatch
安装与配置
bash
# 安装
npm install --save-dev nightwatch chromedriver
# Vue CLI 插件方式
vue add e2e-nightwatch配置文件
javascript
// nightwatch.conf.js
module.exports = {
src_folders: ['tests/e2e'],
output_folder: 'tests/e2e/reports',
test_settings: {
default: {
launch_url: 'http://localhost:8080',
screenshots: {
enabled: true,
path: 'tests/e2e/screenshots'
},
desiredCapabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless', '--no-sandbox']
}
},
webdriver: {
start_process: true,
server_path: require('chromedriver').path
}
},
chrome: {
desiredCapabilities: {
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--window-size=1280,720']
}
}
},
firefox: {
desiredCapabilities: {
browserName: 'firefox'
},
webdriver: {
start_process: true,
server_path: require('geckodriver').path
}
}
}
}基本语法
javascript
// tests/e2e/login.spec.js
module.exports = {
'登录页面测试': function (browser) {
const login = browser.page.loginPage()
login.navigate()
.assert.title('Login')
.assert.visible('@emailInput')
.setValue('@emailInput', 'test@example.com')
.setValue('@passwordInput', 'password123')
.click('@submitBtn')
.waitForElementVisible('@welcomeMessage', 5000)
browser.end()
}
}Page Object 模式
javascript
// tests/e2e/pages/loginPage.js
module.exports = {
url: '/login',
elements: {
emailInput: 'input[name="email"]',
passwordInput: 'input[name="password"]',
submitBtn: 'button[type="submit"]',
errorMessage: '.error-message',
welcomeMessage: '.welcome-message'
},
commands: [{
login(email, password) {
return this
.setValue('@emailInput', email)
.setValue('@passwordInput', password)
.click('@submitBtn')
},
expectError(message) {
return this.assert.containsText('@errorMessage', message)
}
}]
}三、测试策略
测试金字塔
code
/\
/E2E\ 10% - 关键流程
/------\
/ 组件测试 \ 20% - 核心组件
/----------\
/ 单元测试 \ 70% - 工具/逻辑
/--------------\测试优先级
| 优先级 | 测试内容 | 原因 |
|---|---|---|
| P0 | 用户注册/登录 | 核心功能入口 |
| P0 | 支付流程 | 关键业务 |
| P0 | 核心业务流程 | 主要收入来源 |
| P1 | 数据展示页面 | 用户主要操作 |
| P1 | 表单提交 | 数据创建 |
| P2 | 次要功能 | 使用频率低 |
测试数据管理
javascript
// cypress/fixtures/users.json
{
"validUser": {
"email": "test@example.com",
"password": "Test123!"
},
"adminUser": {
"email": "admin@example.com",
"password": "Admin123!"
}
}
// 使用 fixture
cy.fixture('users').then((users) => {
cy.get('[data-testid="email"]').type(users.validUser.email)
cy.get('[data-testid="password"]').type(users.validUser.password)
})环境变量
javascript
// cypress.json
{
"env": {
"apiUrl": "http://localhost:3000/api"
}
}
// 使用环境变量
const apiUrl = Cypress.env('apiUrl')
cy.request(`${apiUrl}/users`)
// 命令行传递
// npx cypress run --env apiUrl=http://api.example.com四、最佳实践
1. 使用 data-testid
Vue SFC
<template>
<!-- 推荐 -->
<button data-testid="submit-btn">Submit</button>
<!-- 不推荐 -->
<button class="btn btn-primary btn-large">Submit</button>
</template>2. 独立测试
javascript
// 每个测试应该独立运行
beforeEach(() => {
cy.resetDatabase()
cy.clearCookies()
cy.visit('/')
})3. 明确等待
javascript
// 推荐:等待元素可见
cy.get('[data-testid="loading"]').should('not.exist')
cy.get('[data-testid="data"]').should('be.visible')
// 不推荐:固定等待
cy.wait(5000)4. 可读性
javascript
// 推荐:清晰的描述
it('用户输入有效邮箱和密码后应该成功登录', () => {})
// 不推荐:模糊的描述
it('测试登录', () => {})5. 错误处理
javascript
it('应该正确处理网络错误', () => {
cy.intercept('GET', '/api/data', { forceNetworkError: true })
cy.visit('/dashboard')
cy.get('[data-testid="error-message"]')
.should('contain', '网络错误,请稍后重试')
})五、CI/CD 集成
GitHub Actions
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run E2E tests
uses: cypress-io/github-action@v5
with:
start: npm run serve
wait-on: 'http://localhost:8080'
wait-on-timeout: 120
- name: Upload screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshotsDocker 集成
dockerfile
# Dockerfile for E2E testing
FROM cypress/included:12.0.0
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx cypress verify六、常见问题
Q1: 测试不稳定怎么办?
- 使用明确的等待条件
- 避免
cy.wait()固定等待 - Mock 不稳定的外部依赖
- 使用
cy.retry()增加重试
javascript
// 配置重试
// cypress.json
{
"retries": {
"runMode": 2,
"openMode": 0
}
}Q2: 如何测试文件下载?
javascript
cy.get('[data-testid="download-btn"]').click()
cy.readFile('cypress/downloads/file.pdf').should('exist')Q3: 如何测试多标签页?
javascript
cy.get('a[target="_blank"]').invoke('removeAttr', 'target').click()
cy.url().should('include', '/new-page')Q4: 如何在测试中访问 Vue 组件?
javascript
cy.window().then((win) => {
const app = win.__VUE_APP__
console.log(app.$store.state)
})相关资源
下一步
- 单元测试 - 回顾单元测试基础和 Jest 配置