问题场景:表单验证模块频繁出bug
系统里有个用户注册表单,包含手机号、密码强度、验证码等校验逻辑。由于多人协作,经常出现:
- A改了手机号校验正则,B的密码强度检测突然失效
- 修复一个边界case,引入另一个bug
- 回归测试要手动点20次表单才能确认
最致命的是,这些逻辑都混在Vue组件里,根本没法单独验证。
解决方案:把校验逻辑拆出来单独测试
先把校验工具函数独立成validators.js
:
javascript
// utils/validators.js
export const validatePhone = (phone) => {
const regex = /^1[3-9]\d{9}$/; // 🔍 中国手机号正则
return {
isValid: regex.test(phone),
error: phone ? '手机号格式不正确' : '手机号不能为空' // 🔍 空值特殊处理
};
};
export const validatePassword = (password) => {
const hasLength = password.length >= 8;
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /\d/.test(password);
return {
isValid: hasLength && hasLetter && hasNumber,
score: [hasLength, hasLetter, hasNumber].filter(Boolean).length, // 🔍 强度评分
error: !password ? '密码不能为空' :
!hasLength ? '密码至少8位' :
!hasLetter ? '需包含字母' :
!hasNumber ? '需包含数字'
};
};
现在可以写第一个测试了:
javascript
// tests/validators.spec.js
import { validatePhone, validatePassword } from '../utils/validators';
describe('手机号校验', () => {
test('正常手机号应通过', () => {
const result = validatePhone('13812345678');
expect(result.isValid).toBe(true); // ✅ 断言结果
});
test('空值应报错', () => {
const result = validatePhone('');
expect(result.isValid).toBe(false);
expect(result.error).toBe('手机号不能为空');
});
test('错误格式应拦截', () => {
const result = validatePhone('12345');
expect(result.isValid).toBe(false);
expect(result.error).toBe('手机号格式不正确');
});
});
关键点在于:每个test只验证一个具体场景。不是在测试函数能不能运行,而是在验证业务规则是否被正确实现。
原理剖析:测试金字塔的底层逻辑
%% =========================================================
%% 专业级「测试金字塔」可视化
%% 主题:测试策略分层与占比
%% 适配:深色 / 浅色模式自适应
%% 图标:FontAwesome 6.5
%% =========================================================
%% 1. 全局样式
%% 2. 金字塔节点
%% 3. 占比标签
%% 4. 悬停提示
%% 5. 动效建议
%% ========== 1. 全局样式 ==========
%% 主色:#10b981(emerald-500)
%% 辅色:#0ea5e9(sky-500)
%% 警告:#f59e0b(amber-500)
%% 背景:#ffffff / #111827(自适应)
%% 字体:Inter, system-ui, sans-serif
%% 圆角:8px
%% 阴影:0 4px 6px -1px rgba(0,0,0,0.1)
graph TD
classDef e2e fill:#ef4444,stroke:#dc2626,color:#fff,stroke-width:2px,rx:8px,ry:8px
classDef integ fill:#f59e0b,stroke:#d97706,color:#fff,stroke-width:2px,rx:8px,ry:8px
classDef unit fill:#10b981,stroke:#059669,color:#fff,stroke-width:2px,rx:8px,ry:8px
classDef label fill:none,stroke:none,color:#374151,font-weight:600
%% ========== 2. 金字塔节点 ==========
%% 使用梯形节点模拟金字塔
A[" E2E 测试"]:::e2e
B[" 集成测试"]:::integ
C[" 单元测试"]:::unit
%% ========== 3. 占比标签 ==========
A1["
5%
"]:::label
B1["20%
"]:::label
C1["75%
"]:::label
%% ========== 4. 悬停提示(title 属性) ==========
click A "模拟真实用户操作:覆盖完整业务流程,成本高、速度慢"
click B "组件间协作:验证模块接口与数据流,平衡速度与范围"
click C "函数级验证:快速反馈、成本低,当前所处层级"
%% ========== 5. 动效建议 ==========
%% 部署时可通过 CSS 实现:
%% .node[id*="C"] { animation: pulse 2s infinite; }
%% 金字塔结构
A --> B
B --> C
A1 -.-> A
B1 -.-> B
C1 -.-> C单元测试的核心是隔离 。看validatePhone
的测试流程:
scss
测试执行时序:
输入数据 → 调用被测函数 → 获取返回值 → 断言验证
↓ ↓ ↓ ↓
'138...' → validatePhone() → {isValid:true} → expect(...).toBe(true)
Jest会:
- 创建独立执行环境
- 注入测试数据
- 捕获函数返回值
- 用matcher进行断言
- 生成覆盖率报告
应用扩展:测试Vue组件的三种姿势
当逻辑进入组件层,情况变得复杂。比如这个密码输入框:
vue
<!-- components/PasswordInput.vue -->
<template>
<div class="password-field">
<input
v-model="password"
type="password"
@input="handleInput"
/>
<div v-if="error" class="error">{{ error }}</div>
<div class="strength-bar" :class="getStrengthClass()">
强度: {{ strengthText }}
</div>
</div>
</template>
<script>
import { validatePassword } from '@/utils/validators';
export default {
data() {
return {
password: '',
error: ''
};
},
computed: {
strengthText() {
const { score } = validatePassword(this.password);
return ['极弱', '弱', '中等', '强'][score] || '';
}
},
methods: {
handleInput() {
const { isValid, error } = validatePassword(this.password);
this.error = isValid ? '' : error;
this.$emit('update:valid', isValid); // 🔍 通知父组件
},
getStrengthClass() {
return `level-${validatePassword(this.password).score}`;
}
}
};
</script>
测试策略要分层:
javascript
// tests/PasswordInput.spec.js
import { mount } from '@vue/test-utils';
import PasswordInput from '@/components/PasswordInput.vue';
describe('PasswordInput组件', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(PasswordInput);
});
test('输入弱密码显示错误提示', async () => {
await wrapper.find('input').setValue('123');
await wrapper.vm.$nextTick();
expect(wrapper.find('.error').text())
.toBe('密码至少8位'); // ✅ UI反馈验证
expect(wrapper.emitted('update:valid')[0])
.toEqual([false]); // ✅ 事件触发验证
});
test('密码强度条正确更新', async () => {
await wrapper.find('input').setValue('abc12345');
await wrapper.vm.$nextTick();
expect(wrapper.find('.strength-bar').classes())
.toContain('level-3'); // ✅ 样式类正确
});
});
这里的关键是:
mount
创建真实DOM环境setValue
模拟用户输入emitted()
捕获组件事件$nextTick
等待视图更新
主流方案对比:选型决策表
方案 | 优势 | 劣势 | 适用场景 |
---|---|---|---|
Jest + Vue Test Utils | 零配置、快照测试、覆盖率报告 | 需要学习wrapper API | Vue项目标配 |
Vitest | 速度极快、Vite原生支持 | 生态较新 | 新项目首选 |
Cypress Component | 可视化调试、接近真实环境 | 启动慢、资源占用高 | 复杂交互组件 |
Jest DOM | 简单断言、轻量 | 功能有限 | 纯工具函数 |
最终选择Vitest,因为:
javascript
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './tests/setup.js', // 🔍 全局配置
coverage: {
provider: 'istanbul',
reporter: ['text', 'html'] // 🔍 生成覆盖率报告
}
}
});
举一反三:三个变体场景
场景1:异步校验(验证码)
javascript
// 需要模拟API调用
test('验证码错误显示提示', async () => {
vi.mock('@/api/sms', () => ({
checkCode: vi.fn().mockResolvedValue({ valid: false })
}));
await wrapper.find('#code-input').setValue('1234');
expect(wrapper.text()).toContain('验证码错误');
});
场景2:组合式API
javascript
// 测试useForm composable
test('useForm处理表单重置', () => {
const { formData, reset } = useForm();
formData.value.name = 'test';
reset();
expect(formData.value.name).toBe('');
});
场景3:第三方依赖
javascript
// 模拟moment.js
vi.mock('moment', () => ({
default: vi.fn(() => ({
format: vi.fn(() => '2025-08-03')
}))
}));
绿色的测试通过提示就像在说:"放心改,兄弟,我罩着你"。