Files
workspace/code/fms/fms-vue/tests
oneao 29f98494af feat(module-management): 导航状态化阶段三——概览配置完成度 checklist
- 新组件 ModuleOverviewChecklist:字段/表单/列表/查询/权限按依赖顺序排列,
  状态以文案表达(缺 N/已配置 N,不只靠颜色),空态给引导文案
- 条目点击与 Enter 跳转对应分节;查询缺配提供「一键补充」
  (复用 fillMissingConfigs,不冒泡为跳转);仅数据/查询模块渲染
- index.vue 补 fieldsEnabledCount/listVisibleCount/powersCount 派生统计;
  表单/查询缺配复用阶段二/既有的 missingEditCount/missingQueryCount
- 新 spec module-overview-checklist 7 用例:条目显隐、缺配/完整/空态文案、
  跳转与一键补充事件
2026-08-30 20:10:53 +08:00
..
2026-08-19 17:32:25 +08:00
2026-08-19 17:32:25 +08:00
2026-08-27 17:32:05 +08:00
2026-08-19 17:32:25 +08:00
2026-08-19 17:32:25 +08:00
2026-08-19 17:32:25 +08:00
2026-08-19 17:32:25 +08:00

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 这一个模块:

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 测试

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)

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)

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 节流程为准。