第38天:Element Plus 测试策略与质量保证
学习目标
- 深入理解 Element Plus 的测试体系
- 掌握组件单元测试的编写方法
- 学习集成测试和端到端测试
- 实践测试驱动开发(TDD)
学习内容
1. Element Plus 测试体系概述
1.1 测试金字塔
typescript
// Element Plus 测试层次结构
/*
E2E Tests (端到端测试)
↑
Integration Tests (集成测试)
↑
Unit Tests (单元测试)
↑
Static Analysis (静态分析)
*/
// 测试配置文件结构
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.d.ts',
'dist/'
]
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
1.2 测试环境配置
typescript
// test/setup.ts - 测试环境设置
import { config } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { vi } from 'vitest'
// 全局组件注册
config.global.plugins = [ElementPlus]
// 模拟浏览器 API
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// 模拟 ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// 模拟 IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// 模拟 getComputedStyle
Object.defineProperty(window, 'getComputedStyle', {
value: () => ({
getPropertyValue: () => '',
display: 'none',
visibility: 'hidden',
width: '0px',
height: '0px'
})
})
2. 组件单元测试
2.1 基础组件测试
typescript
// test/components/Button.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ElButton } from 'element-plus'
describe('ElButton', () => {
// 基础渲染测试
it('renders correctly', () => {
const wrapper = mount(ElButton, {
props: {
type: 'primary'
},
slots: {
default: 'Click me'
}
})
expect(wrapper.classes()).toContain('el-button')
expect(wrapper.classes()).toContain('el-button--primary')
expect(wrapper.text()).toBe('Click me')
})
// Props 测试
it('handles size prop correctly', () => {
const wrapper = mount(ElButton, {
props: {
size: 'large'
}
})
expect(wrapper.classes()).toContain('el-button--large')
})
// 事件测试
it('emits click event', async () => {
const wrapper = mount(ElButton)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
// 禁用状态测试
it('handles disabled state', async () => {
const wrapper = mount(ElButton, {
props: {
disabled: true
}
})
expect(wrapper.classes()).toContain('is-disabled')
expect(wrapper.attributes('disabled')).toBeDefined()
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
})
// 加载状态测试
it('shows loading state', () => {
const wrapper = mount(ElButton, {
props: {
loading: true
}
})
expect(wrapper.classes()).toContain('is-loading')
expect(wrapper.find('.el-icon-loading').exists()).toBe(true)
})
// 图标测试
it('renders icon correctly', () => {
const wrapper = mount(ElButton, {
props: {
icon: 'el-icon-search'
}
})
expect(wrapper.find('.el-icon-search').exists()).toBe(true)
})
})
2.2 复杂组件测试
typescript
// test/components/Form.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
import { nextTick } from 'vue'
describe('ElForm', () => {
let wrapper: VueWrapper<any>
const formData = {
username: '',
email: '',
password: ''
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 15, message: '长度在 3 到 15 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
}
beforeEach(() => {
wrapper = mount({
template: `
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" type="email" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">提交</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
`,
components: {
ElForm,
ElFormItem,
ElInput,
ElButton
},
data() {
return {
form: { ...formData },
rules
}
},
methods: {
submitForm() {
this.$refs.formRef.validate((valid) => {
if (valid) {
this.$emit('submit', this.form)
}
})
},
resetForm() {
this.$refs.formRef.resetFields()
}
}
})
})
// 表单渲染测试
it('renders form correctly', () => {
expect(wrapper.find('.el-form').exists()).toBe(true)
expect(wrapper.findAll('.el-form-item')).toHaveLength(4)
expect(wrapper.findAll('.el-input')).toHaveLength(3)
})
// 表单验证测试
it('validates required fields', async () => {
const submitButton = wrapper.find('button[type="button"]')
await submitButton.trigger('click')
await nextTick()
const errorMessages = wrapper.findAll('.el-form-item__error')
expect(errorMessages.length).toBeGreaterThan(0)
})
// 表单数据绑定测试
it('binds form data correctly', async () => {
const usernameInput = wrapper.find('input[type="text"]')
await usernameInput.setValue('testuser')
expect(wrapper.vm.form.username).toBe('testuser')
})
// 表单重置测试
it('resets form correctly', async () => {
// 填写表单
const inputs = wrapper.findAll('input')
await inputs[0].setValue('testuser')
await inputs[1].setValue('test@example.com')
await inputs[2].setValue('password123')
// 重置表单
const resetButton = wrapper.findAll('button')[1]
await resetButton.trigger('click')
await nextTick()
expect(wrapper.vm.form.username).toBe('')
expect(wrapper.vm.form.email).toBe('')
expect(wrapper.vm.form.password).toBe('')
})
// 表单提交测试
it('submits valid form', async () => {
// 填写有效数据
const inputs = wrapper.findAll('input')
await inputs[0].setValue('testuser')
await inputs[1].setValue('test@example.com')
await inputs[2].setValue('password123')
// 提交表单
const submitButton = wrapper.find('button[type="button"]')
await submitButton.trigger('click')
await nextTick()
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0][0]).toEqual({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
})
})
})
2.3 组合式函数测试
typescript
// test/composables/useForm.test.ts
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { useForm } from '@/composables/useForm'
describe('useForm', () => {
it('initializes form state correctly', () => {
const initialData = { name: '', email: '' }
const { formData, errors, isValid } = useForm(initialData)
expect(formData.value).toEqual(initialData)
expect(errors.value).toEqual({})
expect(isValid.value).toBe(true)
})
it('validates form fields', async () => {
const initialData = { name: '', email: '' }
const rules = {
name: [(value: string) => value.length > 0 || '姓名不能为空'],
email: [(value: string) => /\S+@\S+\.\S+/.test(value) || '邮箱格式不正确']
}
const { formData, validate, errors, isValid } = useForm(initialData, rules)
// 验证空表单
await validate()
expect(errors.value.name).toBe('姓名不能为空')
expect(errors.value.email).toBe('邮箱格式不正确')
expect(isValid.value).toBe(false)
// 填写正确数据
formData.value.name = 'John Doe'
formData.value.email = 'john@example.com'
await validate()
expect(errors.value).toEqual({})
expect(isValid.value).toBe(true)
})
it('resets form correctly', () => {
const initialData = { name: '', email: '' }
const { formData, reset } = useForm(initialData)
// 修改数据
formData.value.name = 'John'
formData.value.email = 'john@example.com'
// 重置
reset()
expect(formData.value).toEqual(initialData)
})
})
3. 集成测试
3.1 组件交互测试
typescript
// test/integration/FormDialog.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
import { nextTick } from 'vue'
const FormDialog = {
template: `
<el-dialog
v-model="visible"
title="用户信息"
width="500px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm">确认</el-button>
</template>
</el-dialog>
`,
components: {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElButton
},
props: {
modelValue: Boolean,
userData: Object
},
emits: ['update:modelValue', 'confirm', 'cancel'],
data() {
return {
form: {
name: '',
email: ''
},
rules: {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }]
}
}
},
computed: {
visible: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
}
}
},
watch: {
userData: {
handler(newData) {
if (newData) {
Object.assign(this.form, newData)
}
},
immediate: true
}
},
methods: {
handleClose() {
this.resetForm()
},
handleCancel() {
this.visible = false
this.$emit('cancel')
},
handleConfirm() {
this.$refs.formRef.validate((valid) => {
if (valid) {
this.$emit('confirm', { ...this.form })
this.visible = false
}
})
},
resetForm() {
this.form = { name: '', email: '' }
this.$refs.formRef?.clearValidate()
}
}
}
describe('FormDialog Integration', () => {
it('opens and closes dialog correctly', async () => {
const wrapper = mount(FormDialog, {
props: {
modelValue: false
}
})
expect(wrapper.find('.el-dialog').isVisible()).toBe(false)
await wrapper.setProps({ modelValue: true })
await nextTick()
expect(wrapper.find('.el-dialog').isVisible()).toBe(true)
})
it('handles form submission correctly', async () => {
const wrapper = mount(FormDialog, {
props: {
modelValue: true,
userData: { name: 'John', email: 'john@example.com' }
}
})
await nextTick()
// 检查数据是否正确填充
const nameInput = wrapper.find('input[type="text"]')
expect(nameInput.element.value).toBe('John')
// 点击确认按钮
const confirmButton = wrapper.find('.el-button--primary')
await confirmButton.trigger('click')
await nextTick()
// 检查事件是否正确触发
expect(wrapper.emitted('confirm')).toBeTruthy()
expect(wrapper.emitted('confirm')[0][0]).toEqual({
name: 'John',
email: 'john@example.com'
})
})
it('validates form before submission', async () => {
const wrapper = mount(FormDialog, {
props: {
modelValue: true
}
})
await nextTick()
// 点击确认按钮(表单为空)
const confirmButton = wrapper.find('.el-button--primary')
await confirmButton.trigger('click')
await nextTick()
// 检查验证错误
const errorMessages = wrapper.findAll('.el-form-item__error')
expect(errorMessages.length).toBeGreaterThan(0)
// 确认事件不应该被触发
expect(wrapper.emitted('confirm')).toBeFalsy()
})
})
4. 端到端测试
4.1 Playwright E2E 测试
typescript
// e2e/form-submission.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Form Submission Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/form-demo')
})
test('should submit form successfully', async ({ page }) => {
// 填写表单
await page.fill('[data-testid="username-input"]', 'testuser')
await page.fill('[data-testid="email-input"]', 'test@example.com')
await page.fill('[data-testid="password-input"]', 'password123')
// 提交表单
await page.click('[data-testid="submit-button"]')
// 验证成功消息
await expect(page.locator('.el-message--success')).toBeVisible()
await expect(page.locator('.el-message--success')).toContainText('提交成功')
})
test('should show validation errors for empty form', async ({ page }) => {
// 直接点击提交按钮
await page.click('[data-testid="submit-button"]')
// 验证错误消息
await expect(page.locator('.el-form-item__error')).toHaveCount(3)
await expect(page.locator('.el-form-item__error').first()).toContainText('请输入用户名')
})
test('should reset form correctly', async ({ page }) => {
// 填写表单
await page.fill('[data-testid="username-input"]', 'testuser')
await page.fill('[data-testid="email-input"]', 'test@example.com')
// 重置表单
await page.click('[data-testid="reset-button"]')
// 验证表单已清空
await expect(page.locator('[data-testid="username-input"]')).toHaveValue('')
await expect(page.locator('[data-testid="email-input"]')).toHaveValue('')
})
test('should handle network errors gracefully', async ({ page }) => {
// 模拟网络错误
await page.route('**/api/submit', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' })
})
})
// 填写并提交表单
await page.fill('[data-testid="username-input"]', 'testuser')
await page.fill('[data-testid="email-input"]', 'test@example.com')
await page.fill('[data-testid="password-input"]', 'password123')
await page.click('[data-testid="submit-button"]')
// 验证错误消息
await expect(page.locator('.el-message--error')).toBeVisible()
await expect(page.locator('.el-message--error')).toContainText('提交失败')
})
})
4.2 Cypress E2E 测试
typescript
// cypress/e2e/table-operations.cy.ts
describe('Table Operations', () => {
beforeEach(() => {
cy.visit('/table-demo')
})
it('should load table data correctly', () => {
cy.get('[data-testid="data-table"]').should('be.visible')
cy.get('.el-table__row').should('have.length.greaterThan', 0)
})
it('should sort table by column', () => {
// 点击姓名列排序
cy.get('[data-testid="name-column"] .caret-wrapper').click()
// 验证排序结果
cy.get('.el-table__row').first().should('contain', 'Alice')
// 再次点击反向排序
cy.get('[data-testid="name-column"] .caret-wrapper').click()
cy.get('.el-table__row').first().should('contain', 'Zoe')
})
it('should filter table data', () => {
// 输入搜索关键词
cy.get('[data-testid="search-input"]').type('John')
// 验证过滤结果
cy.get('.el-table__row').should('have.length', 1)
cy.get('.el-table__row').should('contain', 'John')
})
it('should paginate table correctly', () => {
// 检查分页器
cy.get('.el-pagination').should('be.visible')
// 点击下一页
cy.get('.el-pagination .btn-next').click()
// 验证页码变化
cy.get('.el-pagination .number.active').should('contain', '2')
})
it('should select and delete rows', () => {
// 选择第一行
cy.get('.el-table__row').first().find('.el-checkbox').click()
// 点击删除按钮
cy.get('[data-testid="delete-button"]').click()
// 确认删除
cy.get('.el-message-box__btns .el-button--primary').click()
// 验证删除成功
cy.get('.el-message--success').should('be.visible')
})
})
5. 性能测试
5.1 组件性能测试
typescript
// test/performance/component-performance.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { ElTable, ElTableColumn } from 'element-plus'
import { performance } from 'perf_hooks'
describe('Component Performance', () => {
it('should render large table efficiently', () => {
const startTime = performance.now()
// 生成大量数据
const data = Array.from({ length: 1000 }, (_, index) => ({
id: index,
name: `User ${index}`,
email: `user${index}@example.com`,
age: 20 + (index % 50)
}))
const wrapper = mount({
template: `
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="email" label="邮箱" width="200" />
<el-table-column prop="age" label="年龄" width="80" />
</el-table>
`,
components: {
ElTable,
ElTableColumn
},
data() {
return {
tableData: data
}
}
})
const endTime = performance.now()
const renderTime = endTime - startTime
// 渲染时间应该在合理范围内(小于1秒)
expect(renderTime).toBeLessThan(1000)
expect(wrapper.findAll('.el-table__row')).toHaveLength(1000)
})
it('should handle frequent updates efficiently', async () => {
const data = Array.from({ length: 100 }, (_, index) => ({
id: index,
value: Math.random()
}))
const wrapper = mount({
template: `
<div>
<div v-for="item in items" :key="item.id">
{{ item.value.toFixed(2) }}
</div>
</div>
`,
data() {
return {
items: data
}
}
})
const startTime = performance.now()
// 执行100次更新
for (let i = 0; i < 100; i++) {
wrapper.vm.items.forEach(item => {
item.value = Math.random()
})
await wrapper.vm.$nextTick()
}
const endTime = performance.now()
const updateTime = endTime - startTime
// 更新时间应该在合理范围内
expect(updateTime).toBeLessThan(2000)
})
})
6. 测试工具与辅助函数
6.1 测试工具函数
typescript
// test/utils/test-helpers.ts
import { mount, VueWrapper } from '@vue/test-utils'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// 创建带有 Element Plus 配置的包装器
export function createWrapper(component: any, options: any = {}) {
return mount(component, {
global: {
components: {
ElConfigProvider
},
provide: {
locale: zhCn
}
},
...options
})
}
// 等待异步操作完成
export async function waitFor(condition: () => boolean, timeout = 5000) {
const startTime = Date.now()
while (!condition() && Date.now() - startTime < timeout) {
await new Promise(resolve => setTimeout(resolve, 10))
}
if (!condition()) {
throw new Error(`Condition not met within ${timeout}ms`)
}
}
// 模拟用户输入
export async function userInput(wrapper: VueWrapper<any>, selector: string, value: string) {
const input = wrapper.find(selector)
await input.setValue(value)
await input.trigger('input')
await input.trigger('blur')
}
// 模拟表单提交
export async function submitForm(wrapper: VueWrapper<any>, formSelector = '.el-form') {
const form = wrapper.find(formSelector)
await form.trigger('submit')
}
// 检查元素是否可见
export function isVisible(wrapper: VueWrapper<any>, selector: string): boolean {
const element = wrapper.find(selector)
return element.exists() && element.isVisible()
}
// 获取表单验证错误
export function getFormErrors(wrapper: VueWrapper<any>): string[] {
return wrapper.findAll('.el-form-item__error').map(error => error.text())
}
// 模拟网络请求
export function mockApiResponse(url: string, response: any, status = 200) {
return vi.fn().mockImplementation(() =>
Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(response)
})
)
}
6.2 自定义测试匹配器
typescript
// test/utils/custom-matchers.ts
import { expect } from 'vitest'
// 扩展 expect 匹配器
expect.extend({
toBeVisible(received) {
const pass = received.exists() && received.isVisible()
return {
pass,
message: () =>
pass
? `Expected element not to be visible`
: `Expected element to be visible`
}
},
toHaveClass(received, className) {
const pass = received.classes().includes(className)
return {
pass,
message: () =>
pass
? `Expected element not to have class "${className}"`
: `Expected element to have class "${className}"`
}
},
toHaveFormError(received, errorMessage) {
const errors = received.findAll('.el-form-item__error')
const hasError = errors.some(error => error.text().includes(errorMessage))
return {
pass: hasError,
message: () =>
hasError
? `Expected form not to have error "${errorMessage}"`
: `Expected form to have error "${errorMessage}"`
}
}
})
// 类型声明
declare module 'vitest' {
interface Assertion<T = any> {
toBeVisible(): T
toHaveClass(className: string): T
toHaveFormError(errorMessage: string): T
}
}
7. 测试覆盖率与质量保证
7.1 覆盖率配置
typescript
// vitest.config.ts - 覆盖率配置
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
exclude: [
'node_modules/',
'test/',
'**/*.d.ts',
'dist/',
'**/*.config.{js,ts}',
'**/mock/**'
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
}
})
7.2 质量检查脚本
json
// package.json - 测试脚本
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test:run && npm run test:e2e",
"test:watch": "vitest --watch",
"test:debug": "vitest --inspect-brk --no-coverage"
}
}
学习资源
作业
- 为自定义组件编写完整的单元测试
- 实现一个复杂表单的集成测试
- 编写端到端测试覆盖主要用户流程
- 设置测试覆盖率目标并达到要求
下一步
明天我们将学习 Element Plus 的性能优化策略。