Files
workspace/code/fms/fms-vue/tests/README.md
T
2026-08-19 17:32:25 +08:00

272 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 节流程为准。