工具线跑到第 8 个月的时候,有个数字让我坐不住了:核心模块的单元测试覆盖率只有 47%。
13 个金融计算工具,覆盖率不到一半。换句话说,超过一半的代码改了之后根本不知道有没有把别的东西改坏。这在金融场景里是定时炸弹——你不可能靠人工 review 保证金融计算的正确性。
于是我们花了三个月建了一套质量基线:Vitest 跑单测、Playwright 跑 e2e、ESLint 守代码风格。到年底,核心模块覆盖率拉到 93%。这篇讲怎么做的。
为什么是 Vitest,不是 Jest
Vue 2.7 + Webpack + TypeScript 这套技术栈,选 Jest 还是 Vitest,我纠结过一阵。最后选 Vitest 的原因:
- 和 Vite 生态零配置:我们的组件库和新工具都在用 Vite,Vitest 直接复用 Vite 的配置,不用单独维护一份 jest.config。
- 速度:Vitest 默认用 esbuild 做转译,比 Jest 的 babel 快一大截。我们的测试套件从 Jest 迁过来后,跑完全量从 90 秒降到 35 秒。
- API 几乎兼容 Jest:
describe、it、expect、vi.mock 这些写法迁移成本极低。
如果你已经在用 Vite,选 Vitest 几乎是唯一合理的答案。
Vitest 配置
根目录的 vitest.config.ts,workspace 级别统一管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import { defineConfig } from 'vitest/config'; import vue from '@vitejs/plugin-vue'; import { resolve } from 'path';
export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': resolve(__dirname, 'src'), }, }, test: { globals: true, environment: 'jsdom', coverage: { provider: 'v8', reporter: ['text', 'lcov', 'html'], reportsDirectory: './coverage', thresholds: { statements: 90, branches: 85, functions: 90, lines: 90, }, include: ['src/**/*.{ts,vue,tsx}'], exclude: ['src/**/*.d.ts', 'src/**/__mocks__/**'], }, setupFiles: ['./test/setup.ts'], }, });
|
thresholds 这块是关键——它不是建议,是硬性门槛。覆盖率低于这个数字,vitest --coverage 直接退出码非零,CI 会挂掉。
覆盖率门槛不是”尽量达到”,是”达不到就别想合并”。
单元测试示例:金融组件
拿金额输入框 AmountInput 举例。这个组件看似简单,但金融场景下有一堆边界情况要覆盖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import { mount } from '@vue/test-utils'; import { describe, it, expect } from 'vitest'; import AmountInput from './AmountInput.vue';
describe('AmountInput', () => { it('格式化输入为千分位', async () => { const wrapper = mount(AmountInput); await wrapper.find('input').setValue('1234567.89'); expect(wrapper.find('input').element.value).toBe('1,234,567.89'); });
it('拒绝非数字输入', async () => { const wrapper = mount(AmountInput); await wrapper.find('input').setValue('abc'); expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']); });
it('负数金额标红', async () => { const wrapper = mount(AmountInput); await wrapper.find('input').setValue('-500'); expect(wrapper.classes()).toContain('amount-input--negative'); });
it('超过最大值时触发 error 事件', async () => { const wrapper = mount(AmountInput, { props: { max: 1000000 }, }); await wrapper.find('input').setValue('2000000'); expect(wrapper.emitted('error')).toBeTruthy(); }); });
|
注意这些测试用例不是拍脑袋想的——每一个都对应一个真实的金融业务约束。千分位格式化、负数标红、上限校验,这些逻辑如果在某个工具里坏了,客户看到的数字就是错的。
Playwright:端到端测试
单测覆盖了组件级别的逻辑,但 13 个工具端到端的流程(用户登录 → 选客户 → 填表 → 点计算 → 看报表)需要 e2e 测试来守。
Playwright 的配置放在仓库根目录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: './e2e', fullyParallel: true, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['github'], ['html']] : 'list', use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], webServer: { command: 'pnpm dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, }, });
|
一个典型的 e2e 测试——理财计算工具的完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { test, expect } from '@playwright/test';
test('理财计算完整流程', async ({ page }) => { await page.goto('/wealth-calc');
await page.click('[data-testid="client-select"]'); await page.click('[data-testid="client-option-0"]');
await page.fill('[data-testid="input-amount"]', '500000'); await page.fill('[data-testid="input-years"]', '20');
await page.click('[data-testid="calc-button"]');
await expect(page.locator('[data-testid="result-table"]')).toBeVisible();
const rows = page.locator('[data-testid="result-table"] tbody tr'); await expect(rows).toHaveCount(20);
await expect(page.locator('[data-testid="export-button"]')).toBeEnabled(); });
|
e2e 测试的灵魂在于 data-testid。我们定了一个规范:所有需要测试交互的元素必须有 data-testid 属性,CSS 类名可以变,但 testid 不能变。这样重构时测试不会大面积失效。
覆盖率门槛怎么执行
光有配置不够,得有人守。我们的做法是分两层:
第一层是 pre-commit hook。跑 lint + 受影响包的单测,不通过不让提交:
1 2 3
| #!/bin/bash
pnpm lint-staged
|
1 2 3 4 5 6
| { "lint-staged": { "*.{ts,vue,tsx}": ["eslint --fix", "vitest related --run"] } }
|
第二层是 CI 流水线。跑全量单测 + 覆盖率检查 + e2e,任何一项挂了 PR 不能合并:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| name: Quality Gate on: [pull_request]
jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm test --coverage
e2e-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps - run: pnpm e2e
|
覆盖率是个数字,但数字背后是纪律。没有 CI 卡门,配置写得再漂亮也没用。
ESLint:把规范写进机器里
人的 code review 会遗漏,机器不会。我们的 ESLint 配置在共享包里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| module.exports = { extends: [ 'eslint:recommended', '@vue/eslint-config-typescript', '@vue/eslint-config-typescript/strict', ], rules: { 'no-console': ['error', { allow: ['warn', 'error'] }], '@typescript-eslint/no-explicit-any': 'error', 'no-restricted-globals': ['error', { name: 'parseFloat', message: '金额计算请使用 BigDecimal 或 decimal.js' }], }, };
|
最后那条 no-restricted-globals 是专门为金融场景加的——强制阻止 parseFloat 出现在代码里,因为浮点精度问题在金融场景下是致命的。
小结
质量基线这件事,没有银弹,就是三件事做到位:
- Vitest 跑单测,覆盖率门槛卡死(核心模块 90%+)
- Playwright 跑 e2e,关键流程全覆盖
- ESLint 把规范写进 CI(人能漏的机器不能漏)
测试不是”写完功能再补”,是和功能一起写,甚至先写测试再写实现。
到这一篇为止,工具线的前半段(架构搭建)基本讲完了。下一篇是一个转折——为什么我决定把这套 Vue 2.7 的体系迁到 React 19。