前端的单元测试

问题场景:表单验证模块频繁出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会:

  1. 创建独立执行环境
  2. 注入测试数据
  3. 捕获函数返回值
  4. 用matcher进行断言
  5. 生成覆盖率报告

应用扩展:测试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')
  }))
}));

绿色的测试通过提示就像在说:"放心改,兄弟,我罩着你"。

相关推荐
Wcowin24 分钟前
MkDocs文档日期插件【推荐】
前端·mkdocs
xw51 小时前
免费的个人网站托管-Cloudflare
服务器·前端
网安Ruler1 小时前
Web开发-PHP应用&Cookie脆弱&Session固定&Token唯一&身份验证&数据库通讯
前端·数据库·网络安全·php·渗透·红队
!win !1 小时前
免费的个人网站托管-Cloudflare
服务器·前端·开发工具
饺子不放糖2 小时前
基于BroadcastChannel的前端多标签页同步方案:让用户体验更一致
前端
饺子不放糖2 小时前
前端性能优化实战:从页面加载到交互响应的全链路优化
前端
Jackson__2 小时前
使用 ICE PKG 开发并发布支持多场景引用的 NPM 包
前端
饺子不放糖2 小时前
前端错误监控与异常处理:构建健壮的Web应用
前端
cos2 小时前
FE Bits 前端周周谈 Vol.1|Hello World、TanStack DB 首个 Beta 版发布
前端·javascript·css
饺子不放糖2 小时前
CSS的float布局,让我怀疑人生
前端