一、测试目标与分层策略
测试的目标是让重构有反馈,而不是追求覆盖率数字。纯函数和 Store 状态转换放在快速单元测试,组件验证用户可见交互,少量端到端用例覆盖登录、创建和刷新等关键旅程。
每个缺陷优先在成本最低且能稳定复现的层级补测试。DOM 细节、内部方法名和快照不应成为主要契约;用户可见文本、可访问角色、状态变化和 API 边界才是长期契约。
少量 E2E:登录 → 创建 → 刷新后仍存在
中量组件测试:输入、点击、错误、可访问性
大量单元测试:纯函数、Store action/getter、边界条件二、创建项目并固定依赖
提交 lockfile,并在 CI 使用 npm ci。Node 版本用 .nvmrc、Volta 或 CI 镜像固定;本地与 CI 版本漂移会造成不可复现的依赖、快照和 ESM 行为。
npm create vite@latest task-board -- --template vue-ts
cd task-board
npm install
npm install pinia
npm install -D vitest @vue/test-utils jsdom @pinia/testing vue-tsc
npm pkg set scripts.test='vitest run'
npm pkg set scripts.typecheck='vue-tsc --noEmit'三、配置 Vitest 与 jsdom
setup 文件集中清理浏览器状态和全局 mock,不要让单个用例依赖执行顺序。覆盖率只统计产品源码,生成目录和类型声明不计入。
// vite.config.ts
export default defineConfig({
plugins: [vue()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
test: {
environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'],
coverage: { reporter: ['text','html'], include: ['src/**/*.{ts,vue}'] },
},
})// src/test/setup.ts
import { afterEach, vi } from 'vitest'
afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals() })四、实现类型明确的 Pinia Store
Store 处理领域状态,不直接读写 DOM。HTTP、时钟和存储封装成依赖,测试时替换。输入在边界归一化,找不到实体时明确抛错,不能静默失败。
export type Task = { id:string; title:string; done:boolean }
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const pendingCount = computed(() => tasks.value.filter(t => !t.done).length)
function add(title:string) {
const value = title.trim(); if (!value) throw new Error('title_required')
tasks.value.push({ id:crypto.randomUUID(), title:value, done:false })
}
function toggle(id:string) {
const task = tasks.value.find(t => t.id === id)
if (!task) throw new Error('task_not_found'); task.done = !task.done
}
return { tasks, pendingCount, add, toggle }
})五、测试真实 Action 与 Getter
Store 单元测试使用 createPinia 和 setActivePinia,让 action 真实运行。覆盖正常、边界和错误路径,断言结果状态而不是内部实现。
describe('task store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('normalizes and toggles', () => {
const store = useTaskStore(); store.add(' read docs ')
expect(store.tasks[0].title).toBe('read docs')
expect(store.pendingCount).toBe(1)
store.toggle(store.tasks[0].id)
expect(store.pendingCount).toBe(0)
})
it('rejects empty title', () => {
expect(() => useTaskStore().add(' ')).toThrow('title_required')
})
})六、按用户行为测试组件
createTestingPinia 默认把 action 替换为 spy,适合验证组件是否发出正确命令;若要在组件测试运行真实 action,显式设置 stubActions:false。必须清楚当前测试验证的是调用还是状态。
const wrapper = mount(TaskForm, {
global: { plugins: [createTestingPinia({ createSpy: vi.fn })] },
})
const store = useTaskStore()
await wrapper.get('input').setValue('write tests')
await wrapper.get('form').trigger('submit')
expect(store.add).toHaveBeenCalledWith('write tests')
await wrapper.get('input').setValue(' ')
await wrapper.get('form').trigger('submit')
expect(wrapper.get('[role=alert]').text()).toContain('请输入')七、插件、持久化与 Schema 迁移
Pinia 插件只有安装到应用或测试容器后才运行。持久化测试每次清理 localStorage;格式中加入 schemaVersion,旧数据解析失败时回退到空状态并保留诊断,而不是让页面白屏。
const pinia = createPinia()
pinia.use(({ store }) => store.$subscribe((_m, state) => {
localStorage.setItem(store.$id, JSON.stringify({ schemaVersion:1, state }))
}))
mount(TaskForm, { global: { plugins:[pinia] } })
useTaskStore().add('persist me')
expect(JSON.parse(localStorage.getItem('tasks')!).schemaVersion).toBe(1)八、异步、超时和竞态测试
至少覆盖成功、非 2xx、超时、非法 JSON、重复点击和后发请求先返回。组件卸载或发起新请求时取消旧请求,避免旧响应覆盖新状态。mock 返回应保留 HTTP 语义,不能只返回任意对象。
const response = { ok:true, json:async () => [{ id:'1', title:'remote', done:false }] }
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
await store.load()
expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ signal:expect.any(AbortSignal) }))
expect(store.tasks).toHaveLength(1)九、CI 质量门与稳定性
CI 顺序是锁定安装、类型检查、单元与组件测试、构建。覆盖率阈值保护关键逻辑,不为数字写无意义断言。失败优先排查共享全局状态、未清理计时器、真实网络和脆弱选择器。
name: frontend-quality
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run typecheck
- run: npm run test -- --coverage
- run: npm run build十、验收清单
最终检查测试是否隔离 Pinia、localStorage、mock 和计时器,组件是否用可访问角色与用户文本定位,异常和竞态是否有用例,CI 是否在任一步失败时阻止合并。
- 真实 action 测试状态转换。
- 组件 spy 与真实 action 场景明确区分。
- 不依赖用例顺序和真实网络。
- 关键用户旅程另有少量 E2E 验收。
总结
可维护测试体系把状态逻辑放在 Pinia 真实 action 测试,把交互契约放在组件测试,再以少量 E2E 覆盖关键旅程。明确 stub 行为、隔离浏览器状态并固定 CI 工具链,测试才能真正保护重构。