20260819173221
This commit is contained in:
1 parent
1ffc8e1ac9
commit
a0a4273f4c
326 files changed
+301646
-2314
No files matched your search
@@ -0,0 +1,271 @@
|
||||
# FMS Vue 前端测试规范(AI 全权测试版)
|
||||
|
||||
本文档是 fms-vue 前端测试的**唯一权威约定**。测试工作(编写、运行、补覆盖)默认全权交给 AI 执行,本文档保证 AI 在任何时候都能:
|
||||
|
||||
- 知道**测什么**(每个对象的测试清单)
|
||||
- 知道**怎么写**(mock 模板与装配方式)
|
||||
- 知道**怎么跑**(按文件/页面/全部/覆盖率)
|
||||
- 知道**什么时候算完**(覆盖率门槛与验收标准)
|
||||
|
||||
## 1. 测试命令速查
|
||||
|
||||
| 场景 | 命令 |
|
||||
| --- | --- |
|
||||
| 跑全部测试(CI 风格,一次性) | `pnpm test`(同 `pnpm test:all`) |
|
||||
| 只测一个文件 | `pnpm test:file tests/views/login.spec.js` |
|
||||
| 只测一个页面/功能对应的测试文件 | `pnpm test:file tests/views/<页面>.spec.js` |
|
||||
| 只测一个目录(如全部 store) | `pnpm test:file tests/stores` |
|
||||
| 带覆盖率跑(检查缺口) | `pnpm test:coverage` |
|
||||
| 监听模式(改代码自动重跑) | `pnpm test:watch` |
|
||||
|
||||
注意:`pnpm test` 与 `pnpm test:coverage` 的区别——**只有 `test:coverage` 会检查 60% 阈值**。普通 `pnpm test` 只看用例红绿,不看覆盖率。
|
||||
|
||||
## 2. 目录与命名约定
|
||||
|
||||
```
|
||||
tests/
|
||||
setup.js # 全局环境:jsdom polyfill、nprogress mock(已就绪,勿删)
|
||||
helpers/
|
||||
app.js # 统一装配:createTestPinia / createTestRouter / mountPage(已就绪)
|
||||
unit/ # 纯函数/工具测试(无 DOM 或极轻 DOM)
|
||||
utils-*.spec.js # 对应 src/utils/*
|
||||
theme.spec.js # 对应 src/theme/*
|
||||
ui-utils-*.spec.js # 对应 src/components/ui/utils/*
|
||||
stores/ # pinia store 测试
|
||||
auth.spec.js
|
||||
app.spec.js
|
||||
preferences.spec.js
|
||||
components/ # UI 组件测试(冒烟 + 核心交互)
|
||||
input.spec.js
|
||||
select.spec.js
|
||||
... 每个组件一个文件
|
||||
views/ # 页面测试
|
||||
login.spec.js # 已存在
|
||||
dashboard.spec.js
|
||||
...
|
||||
services/ # http 拦截器/请求层测试
|
||||
http.spec.js
|
||||
router-auth.spec.js # 路由守卫集成测试(已存在,留在根目录)
|
||||
permissions.spec.js # store 测试(已存在,留在根目录,不强制迁移)
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 测试文件一律 `*.spec.js`,与源码文件同名(`login/index.vue` → `tests/views/login.spec.js`)。
|
||||
- **每个被测源码文件最多对应一个 spec**,禁止把多个对象的测试堆进一个文件。
|
||||
- `describe` 第一层用中文描述被测对象(如 `describe('登录页')`);用例名用中文描述业务场景。
|
||||
- 新测试按上表归类;**已存在的测试文件(permissions.spec.js、router-auth.spec.js、views/login.spec.js)不必迁移**,保持原位即可,新增的按新约定放。
|
||||
|
||||
## 3. 编写模板
|
||||
|
||||
### 3.1 页面测试(用 mountPage)
|
||||
|
||||
所有请求都收敛在 `@/services/api`,页面测试只需 mock 这一个模块:
|
||||
|
||||
```js
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import LoginPage from '@/views/login/index.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
// —— 单一接缝:mock 业务 API 层,确定性驱动页面状态 ——
|
||||
vi.mock('@/services/api', () => ({
|
||||
loginApi: vi.fn(),
|
||||
loadDataApi: vi.fn(),
|
||||
}))
|
||||
|
||||
// 命令式组件(Message 等)需要 mock,避免 jsdom 下创建 DOM 容器
|
||||
vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||||
return { Message: m, default: m }
|
||||
})
|
||||
|
||||
import { loginApi } from '@/services/api'
|
||||
|
||||
// 子组件用 shallowMount 自动打桩;需要触发子组件事件时给一个稳定 name
|
||||
const formStub = { name: 'Form', template: '<form class="stub"><slot /></form>' }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
loginApi.mockReset()
|
||||
})
|
||||
|
||||
describe('登录页', () => {
|
||||
it('登录成功:调用 loginApi、写入会话、跳转 redirect', async () => {
|
||||
loginApi.mockResolvedValue({
|
||||
data: { token: 'TK', user: { id: 1, account: 'admin' }, orgid: 'ORG1' },
|
||||
code: 0, success: true,
|
||||
})
|
||||
const { wrapper, router } = await mountPage(LoginPage, {
|
||||
location: '/login?redirect=/dashboard',
|
||||
stubs: { Form: formStub },
|
||||
})
|
||||
await wrapper.findComponent({ name: 'Form' }).vm.$emit('submit', { orgId: 'ORG1', account: 'admin', password: 'secret' })
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(loginApi).toHaveBeenCalledWith('ORG1', 'admin', 'secret')
|
||||
expect(useAuthStore().isAuthenticated).toBe(true)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/dashboard')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `beforeEach` 里必须清理 `localStorage` / `sessionStorage`(persist 插件会写它们,跨用例泄漏)。
|
||||
- 用 `flushPromises()` + `nextTick()` 冲刷异步,不要用固定 `setTimeout`。
|
||||
- 需要路由参数时用 `mountPage(Comp, { location: '/detail/123' })`。
|
||||
|
||||
### 3.2 store 测试
|
||||
|
||||
```js
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { createTestPinia } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({ loadDataApi: vi.fn() }))
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('auth store', () => {
|
||||
it('setSession 写入会话', () => {
|
||||
createTestPinia() // 内部已 install,persist 插件真实注册(见 helpers/app.js 注释)
|
||||
const auth = useAuthStore()
|
||||
auth.setSession({ token: 't', user: { id: 1 } }, 'ORG')
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**必须用 `createTestPinia()`,不要手动 `createPinia()`**:
|
||||
|
||||
- pinia 4 中 `pinia.use()` 在未安装 app 前只把插件放入队列,直接 `setActivePinia(createPinia())` 会让 persist 插件静默失效,store 上缺 `$hydrate`/`$persist`。
|
||||
- `createTestPinia()` 内部用假 app 触发 `install()`,行为与页面测试(经 @vue/test-utils 挂载)一致。
|
||||
|
||||
### 3.3 纯函数测试(unit)
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildTree, sortTree } from '@/utils/tree'
|
||||
|
||||
describe('utils/tree', () => {
|
||||
it('buildTree 按 parentKey 构建树', () => {
|
||||
const list = [
|
||||
{ b_id: 1, b_parent_id: null },
|
||||
{ b_id: 2, b_parent_id: 1 },
|
||||
]
|
||||
const tree = buildTree(list)
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].children).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
纯函数测试**不需要** mountPage / pinia / mock,直接 import 断言即可。
|
||||
|
||||
### 3.4 UI 组件测试(components)
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
|
||||
describe('Input 组件', () => {
|
||||
it('渲染输入框并转发 update:modelValue', async () => {
|
||||
const wrapper = mount(Input, { props: { modelValue: '' } })
|
||||
const input = wrapper.find('[data-slot="input"]')
|
||||
await input.setValue('abc')
|
||||
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['abc'])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
组件测试用 `mount`(组件自身逻辑需要真实渲染);页面测试用 `shallowMount`(经 mountPage)。定位元素优先用组件已有的 `data-slot`、`aria-label`、`role` 等稳定属性,**不要依赖 CSS class 或文本**。
|
||||
|
||||
## 4. 每个对象的测试清单(AI 补测试时逐项对照)
|
||||
|
||||
### 4.1 services(最高优先级,当前 0%)
|
||||
|
||||
`tests/services/http.spec.js` 必须覆盖:
|
||||
|
||||
- [ ] 请求拦截器:有 token 时注入 `Authorization: Bearer <token>`;无 token 不注入
|
||||
- [ ] 响应拦截器:`code === 0` → success
|
||||
- [ ] 响应拦截器:`code === 401` → 清会话 + 跳 `/login?reason=expired`
|
||||
- [ ] 响应拦截器:`code === 40101` → 清会话 + 跳 `/login?reason=session-replaced`
|
||||
- [ ] 响应拦截器:业务错误码 → 抛 `Error` 且带 `code`/`data`
|
||||
- [ ] 响应拦截器:网络异常/超时 → 抛中文错误信息
|
||||
- [ ] 已在 `/login` 页时不重复跳转
|
||||
|
||||
### 4.2 utils(纯函数,零成本高收益)
|
||||
|
||||
- [ ] `src/utils/tree.js`:`buildTree`(空数组/孤儿子节点/多根/自定义 key 名)、`sortTree`(b_xh 排序/递归子节点/兜底 b_id)
|
||||
- [ ] `src/theme/index.js`:`applyPrimaryColor` 写 CSS 变量、`applyDarkMode` 切换 `dark` class
|
||||
- [ ] `src/components/ui/utils/*.js`:`focus.js` / `scroll.js` / `drag.js` / `position.js` 按导出函数逐一声明测试
|
||||
|
||||
### 4.3 stores
|
||||
|
||||
- [ ] `auth`:setSession 兼容 `token`/`access_token`、`orgid`、`user`/`user_info`;clearSession;sessionStorage 旧会话迁移
|
||||
- [ ] `app`:cachedViews 去重;addVisitedTab 去重;removeVisitedTab / closeOthers / closeLeft / closeRight / closeAll 的页签与 routeRefreshKeys 联动;refreshRoute / getRouteRefreshKey
|
||||
- [ ] `preferences`:saveLastLogin 写入、持久化 key 为 `fms-preferences`
|
||||
- [ ] `permissions`:已有 5 个用例,继续补充 `resolvePageId` / `canPower` 分支
|
||||
|
||||
### 4.4 UI 组件(冒烟 + 核心交互,20+ 个)
|
||||
|
||||
每个组件最少 3 个用例:**默认渲染、关键交互、禁用/边界**。按复杂度从低到高:
|
||||
|
||||
- [ ] 简单组件:`button` / `tag` / `switch` / `radio` / `checkbox` / `spin` / `col` / `row`
|
||||
- [ ] 输入类:`input`(default/password/number/textarea 四种模式)、`select`、`date`(date-picker/range-picker/time-select/calendar-panel)
|
||||
- [ ] 布局类:`tabs` / `splitter` / `grid` / `tree` / `dropdown` / `menu` / `popover` / `tooltip`
|
||||
- [ ] 反馈类:`modal` / `drawer` / `message` / `notification` / `pagination` / `form` / `form-item`
|
||||
|
||||
### 4.5 页面
|
||||
|
||||
- [ ] `login`:已有 7 个用例,补充"记住上次登录"回填
|
||||
- [ ] `dashboard`:渲染、菜单/权限入口
|
||||
- [ ] `detail`:id 渲染、计数交互
|
||||
- [ ] `demo`:各组件演示区基本渲染
|
||||
- [ ] `test/TestPage`:基本渲染
|
||||
|
||||
### 4.6 集成
|
||||
|
||||
- [ ] `router-auth.spec.js`:已有 5 个用例(保持全绿)
|
||||
- [ ] 布局:`DefaultLayout` / `AppSidebar` / `AppTopbar` / `AppTabs` / `NavMain` / `NavMenuItem` 菜单渲染与折叠逻辑
|
||||
|
||||
## 5. 覆盖率策略
|
||||
|
||||
- 门槛:**lines/functions/branches/statements 均 ≥ 60%**(见 `vitest.config.js`)。
|
||||
- `pnpm test:coverage` 输出会列出每个文件的未覆盖行号(`Uncovered Line #s`),这是 AI 补测试的**路线图**:按行号定位,按第 4 节清单补齐。
|
||||
- 当前基线约 5%(UI 组件几乎全空),**先做第 4.1–4.3 节(services/utils/stores)**,这些是纯逻辑、无 DOM 依赖、成本最低,可快速把全局覆盖率拉到接近 60%;再做组件冒烟补齐剩余。
|
||||
- 阈值检查只在 `--coverage` 时生效,普通 `pnpm test` 不会因覆盖率失败。
|
||||
|
||||
## 6. 常见坑(写测试前必读)
|
||||
|
||||
1. **pinia 必须用 `createTestPinia()`**,否则 persist 插件不生效(详见 3.2)。
|
||||
2. **persist 的存储格式**:插件 v4 存的是纯 state JSON(`{"token":...}`),**不是** `{state, version}` 旧格式;测试里预置 localStorage 时按 v4 格式写。
|
||||
3. **跨用例泄漏**:凡是测试里创建过 store 或挂载过页面,`beforeEach` 必须 `localStorage.clear()` + `sessionStorage.clear()`。
|
||||
4. **mock 工厂提升**:`vi.mock` 工厂不能引用外部变量,需要的数据要么内联、要么在 `mockImplementation` 里再取。
|
||||
5. **`vi.mock('@/services/api')` 时**要提供被测代码用到的**所有**导出(`loginApi`、`loadDataApi`、`pageDataApi` 等),缺一个就报 undefined。
|
||||
6. **异步断言**:`flushPromises()` + `nextTick()`;不要用 `setTimeout` 猜时序。
|
||||
7. **不 mock 的东西**:`nprogress` 已在 `tests/setup.js` 全局 mock;`ResizeObserver`/`IntersectionObserver`/`matchMedia`/`scrollTo` 已有 polyfill,组件测试直接可用。
|
||||
8. **jsdom 不实现布局**:`offsetHeight`/`getBoundingClientRect` 返回 0,涉及拖拽/滚动/尺寸的断言改为验证状态与事件,不验证像素值。
|
||||
|
||||
## 7. AI 工作流(每次测试任务的固定步骤)
|
||||
|
||||
1. **先跑基线**:`pnpm test` —— 确认现有用例全绿再动手。
|
||||
2. **定位范围**:根据任务(测一个功能/页面/全部)用第 1 节命令锁定目标测试文件。
|
||||
3. **对照清单**:按第 4 节对应对象的清单逐项写用例,不遗漏边界。
|
||||
4. **写测试**:遵循第 3 节模板与第 6 节坑位。
|
||||
5. **跑目标文件**:`pnpm test:file <目标>`,红了就修,直到绿。
|
||||
6. **跑全量 + 覆盖率**:`pnpm test` 全绿、`pnpm test:coverage` 达 60%(或说明缺口与下一步)。
|
||||
7. **汇报**:列出新增测试文件、覆盖提升幅度、未达标项与原因。
|
||||
|
||||
## 8. 与开发规范的关系
|
||||
|
||||
`../开发规范.md` 第 2 节"不主动执行构建、测试"约束的是**改业务代码时**的默认行为;
|
||||
**当任务本身是"测试"时**(如本文档适用的场景),测试命令必须执行,且以本文档第 7 节流程为准。
|
||||
Reference in new issue
Block a user