项目:react-ui | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本篇把「测试」一词在这个仓库里的全部形态摊开:组件单测、hooks 单测、共享测试资产、两类 Playwright e2e、文档三脚本(docs:lint)、导出与类型校验(check:exports / check:published-types)、以及 lint 警告棘轮。测试哲学的论述(「三个投影加一个盲区补光」)已在《06-部署运行》第 8 节,门禁与 CI 的执行顺序已在同篇第 6 节,本篇不重复论证,专注三件事:每一层测试长什么样、在哪里、怎么跑;代表性用例在验证什么;以及哪些地方还没有测试——后者的完整清单是本篇独有的产出。
| 层 | 工具 | 数量 | 位置 | 入口命令 |
|---|---|---|---|---|
| ui 组件与内核单测 | Vitest + Testing Library + jsdom | 336 个测试文件 | packages/ui/src/**(与源码同目录) | pnpm test:packages |
| hooks 单测 | 同上 | 30 个测试文件 | packages/hooks/src/** | 同上 |
| pro 单测 | 同上 | 3 个(每模式 1 个) | packages/@xiaoye-react/pro/src/*/ | 同上 |
| 交互 e2e | Playwright + Vite harness | 8 个 spec(24 条用例) | e2e/interactions/ | pnpm e2e:interactions |
| 截图 e2e | Playwright + 文档站产物 | 1 个 spec(10 条基线) | e2e/screenshots.spec.ts | pnpm e2e:screenshots |
| 文档校验 | 三个 Node 脚本(部分用 ts-morph) | 3 个脚本 | apps/docs/scripts/ | pnpm docs:lint |
| 导出/类型校验 | Node 脚本 + tsc | 2 项 | scripts/validate-package-exports.mjs、ui build 后置 | pnpm check:exports 等 |
| 静态门禁 | ESLint(棘轮)+ Stylelint + cspell + commitlint | — | 根配置 | pnpm lint / pnpm commit |
ui 包 336 个测试文件的内部分布(find 实测):components/ 101 个、form/ 67 个、dates/ 69 个、core/ 12 个,其余分布在 schedule 等子模块——日期与表单两个垂直域的测试密度显著高于通用组件,这与它们「交互链路长」的特征一致(packages/ui/vitest.config.ts 注释因此把 testTimeout 定为 20s)。hooks 包 30 个测试文件覆盖约 29 个 hook(use-hotkeys 目录含 use-hotkeys.test.tsx 与 parse-hotkey.test.ts 两个文件),占 83 个 hook 的三分之一强。
单测的两条基础设施决策值得记录。其一,测试别名指向源码而非产物:packages/ui/vitest.config.ts 把 @xiaoye-react/ui 与 @xiaoye-react/hooks 都 alias 到 src/index.ts,注释说明根因——「经 dist 产物会产生两份 context 实例(UIProvider not found 的根因)」;这也是单测必须排在 build:packages 之后的原因(workspace 协议解析 hooks 的 es/ 产物形态参与类型解析)。其二,线程数钉死为 4:336 个测试文件即 336 次 jsdom 环境,默认按核数并发(本机 11 个 worker)曾把全量拖到 105 分钟并伪造 5 条失败 + 4 处超时,poolOptions.threads.maxThreads: 4 用吞吐换「可复现的红绿」,注释明确这是刻意取舍。
组件层:129 个组件目录中,存在同目录 *.test.tsx 的为 92 个(37 个无,find 口径实测;既往文档口径「91/130 有单测」与本批初稿的「90/39」均为目录计数与测试文件归类的口径差,2026-10-06 回源复核统一为 92/129)。37 个无同目录单测的组件目录完整清单如下(这也是第 13 篇技术债台账的原始数据):
Accordion、ActionIcon、Anchor、AngleSlider、BackgroundImage、Button、CodeHighlight、Collapse、ComboboxPopover、Container、FloatingIndicator、FloatingWindow、Group、Input、InputBase、Loader、Marquee、Menubar、ModalBase、Modals、NavigationProgress、NumberFormatter、OverflowList、Pill、PillsInput、RollingNumber、Scroller、SemiCircleProgress、Stack、Text、Title、Transition、Tree、TreeSelect、Typography、UnstyledButton、Watermark。
这份清单的构成有两个规律:一是基建型组件占三成(Input/InputBase/Button/UnstyledButton/Transition/Group/Stack/Text/Title 这些被大量复用的底座),它们的「没坏」目前由上层组件的单测间接兜底——例如 Input 的行为被 TextInput/Select 等 20+ 个带测试的输入组件覆盖;二是视觉动效型占四成(RollingNumber/Marquee/Watermark/FloatingIndicator/Loader 等),它们的行为天然偏向 jsdom 盲区(动画帧、几何测量),按仓库纪律应进 e2e 而非硬凑单测。真正值得警惕的是第三类:复杂交互型(Tree、TreeSelect、ComboboxPopover、ModalBase、Modals、Menubar)——ModalBase 的模态栈语义、ComboboxPopover 的注册表逻辑都是高密度状态机,目前只有消费方(Modal.test.tsx 等)与 e2e(modal.spec.ts)间接覆盖。
hooks 层:30 个测试文件覆盖的 hook 清单(grep 实测):use-debounced-callback、use-debounced-state、use-debounced-value、use-event-listener、use-fetch、use-hash、use-hotkeys(×2 文件)、use-hover、use-idle、use-input-state、use-interval、use-list-state、use-local-storage、use-map、use-mask、use-mouse、use-os、use-previous、use-queue、use-radial-move、use-session-storage、use-set、use-splitter、use-state-history、use-throttled-value、use-timeout、use-toggle、use-viewport-size、use-window-scroll。未覆盖的 54 个里风险最高的同样是基建件:use-uncontrolled(全库状态模型的法定实现,目前靠 ui 侧 DataTable/Tabs 等测试间接覆盖)、use-id(SSR 一致性)、use-merged-ref、use-focus-trap、use-media-query(含 attachMediaListener,ui peer 下限所系的导出)、use-disclosure。
共享测试资产:packages/@xiaoye-react/tests/src/ 把可复用断言抽成 it-supports-* 系列(dates 域 14 个:it-supports-date-input-props、it-supports-month-props、it-supports-clearable-props、it-supports-on-day-click、it-supports-on-day-keydown、it-supports-on-control-click/key-down/mouse-enter、it-supports-with-next-previous、it-supports-weekdays-props、it-support-header-props、it-supports-months-list-props、it-supports-years-list-props 等)与 core 域工具(axe.ts 无障碍断言、create-context-container.ts),由 ui/pro 经 workspace:* 消费。这套「测试断言库」让 33 个日期组件共享同一套行为契约——是单测 DRY 的样板。
两套 Playwright 配置的职责切分见《06-部署运行》第 5.2 节的对照表,本节补充执行细节。交互 e2e(playwright.interactions.config.ts):testDir: './e2e/interactions',globalSetup: './e2e/interactions/assert-harness.ts' 在起跑前 fetch baseURL 并比对 e2e/harness/index.html 的 <title>——防止「4175 端口被别的项目占用,整批用例对着错误站点跑出满屏 element(s) not found」(该文件注释记录了真实事故);Vite harness(e2e/harness/vite.config.ts,--strictPort)直连 ui/hooks 源码,四个宿主页 index.html/interactions.html/demos.html/app-shell-fixed.html 各配挂载点 tsx。8 个 spec 的分布:modal.spec.ts、slider.spec.ts、rating.spec.ts、inputs.spec.ts、collapse.spec.ts、app-shell.spec.ts、virtual-table.spec.ts(94 行,虚拟滚动与展开行测量)、docs-demos.spec.ts(配 docs-demos-known.json 对文档站 demo 做交互扫描,已知差异白名单化)。timeout 120 秒、fullyParallel。截图 e2e(playwright.config.ts):托管 apps/docs/dist(scripts/serve-docs-dist.mjs 零依赖静态服务器,4173 端口),expect.toHaveScreenshot.maxDiffPixelRatio: 0.02 容忍字体反锯齿差异,animations: 'disabled' 防逐帧差异;基线跨平台不通用,故暂不入 CI(CI 注释原话)。CI 侧仅交互 e2e 独立 job(timeout-minutes: 20,Playwright 浏览器经 actions/cache@v4 按 lockfile 哈希缓存)。
三个脚本(apps/docs/scripts/)合计 656 行,是「文档即测试」的具体实现:
@xiaoye-react/* 的导入与包真实导出一致。它自己实现了一个递归解析 export * 链的符号收集器(parseExports,沿 resolveTarget 逐层解析到具体 .ts/.tsx),另核对:样式副作用导入的子路径存在于目标包 exports(ui 是 style.css——注释说这条「历史上曾大面积写错」)、<InstallScript packages="..."> 引用的包真实存在、demos 与 components 里出现的包名(包括 demo 展示字符串 const code = ... 里的导入——它们不参与编译,写错只在用户复制后才炸);function Wrapper(props: any) 把控件值整体展开,tsc 对这一层完全失明;React 的「does not recognize the prop」告警按 (prop, tag) 全进程只报一次,第二个同名 demo 会一直隐身。实现是静态全集比对——用正则从 packages/ui/src 全部 ts/tsx(排除测试)抽出接口成员名,再并入 @types/react 的原生属性名构成「universe」;@types/react 的 785 个全局属性名全洗成合法,ComboboxProps 表里四行假属性被判绿。最终版用 ts-morph 的类型检查器展平 extends/Omit/Pick/ElementProps 泛型链,并按 tag(input/button 等)并入对应原生属性接口;对多态工厂在运行时注入的 props 有专门的注入名单。跨包继承(如 FloatingWindowProps extends hooks 的 UseFloatingWindowOptions)依赖 hooks 构建产物类型——这就是 docs:lint 必须排在 build:packages 之后的机制原因(CI 注释:本地 es/ 常驻所以从没暴露,干净克隆直接 TS2307)。按「这条测试固化了什么语义」挑五个代表:其一,packages/ui/src/core/UIProvider/merge-ui-theme/merge-ui-theme.test.ts——5 个用例固化主题合并的边界语义(显式 undefined 按未配置处理、不就地污染父主题/默认主题),这些语义因此成为接口契约的一部分(CONTRIBUTING 裁决原则「测试显式编码的行为 > 审查者直觉」的直接受益者);其二,packages/ui/src/components/DataTable/DataTable.test.tsx——固化受控排序「不排数据」语义、选择差集/并集(跨页选中保留)、函数 accessor 排序警告等行为;其三,packages/@xiaoye-react/pro/src/pro-table/ProTable.test.tsx——固化「切换 request 函数即重新请求」的竞态语义与 refreshKey 刷新;其四,packages/ui/src/form/tests/use-form/watch.test.tsx——被 eslint 注释点名为 use-form-watch.ts:34 唯一就地豁免的守门测试(exhaustive-deps 分析不到的 effect 由它看着 [path, callback]);其五,hooks 侧 use-local-storage 与 use-hotkeys 的测试——前者含「换 key 后重读存储值」的反向用例(eslint 注释记载这是棘轮治理中顺带查出的真 bug),后者把 parse-hotkey 纯函数与 DOM 绑定分开测。
--max-warnings=57(根 package.json 的 lint:es,NODE_OPTIONS=--max-old-space-size=8192 对抗 eslint OOM)是一条只许调低不许回涨的总量红线。eslint.config.mjs 里那段长注释是这套机制的灵魂,它把 57 条存量警告分了五类:表单 getter 的 useCallback(fn, [xRef.current]) 是契约(9 处就地 disable 并指向 compiler-stability.test.ts);缺 options 的 11 条已列出实际用到的叶子字段(比提示要求的整对象更精确);缺 context/store 对象的 13 条(对象每渲染新建,补进去等于 effect 每帧重跑);长尾的每渲染新建闭包;以及一条 TDZ 陷阱(use-field.ts:197 写进依赖数组会在渲染期触发 TS2448,永远不能照提示改)。治理史 145 → 78 → 57 三轮,顺带查出两个真 bug(useLocalStorage 换 key 不重读、Carousel 回调固化首帧闭包)。注释还写明了操作纪律:补依赖必须逐站点验证告警确实消失、没改出 parse error、也没产生 unnecessary,改完跑 tsc 与表单全量用例。这条机制的完整政策(豁免必须留注释、不留注释会被清)见 CONTRIBUTING「债务政策」。
把第 2 节的数据按风险排序,给出可执行的补测建议(优先级从高到低):
modal-stack.ts 纯函数,可低成本直测;@xiaoye-react/tests 的 axe 工具补无障碍断言;落地时最常见的实操问题是「我改了一个东西,该补哪层测试」。按改动类型给判断规则:改的是纯函数(排序、索引计算、数据解析、合并语义)——只写单测,参照 sort.ts 与 merge-ui-theme.test.ts 的风格,把边界语义逐条固化成用例;改的是组件状态语义(受控/非受控、事件回调、aria 属性)——写同目录 *.test.tsx,状态双模是必测项;改的是交互手感(pointer 命中、键盘导航的真实事件顺序、transitionend 时序)——jsdom 模拟不了,写 e2e/interactions 用例挂进 harness;改的是纯视觉(间距、圆角、颜色)——截图基线(注意跨平台问题)加 CSS 层断言;改的是类型或导出面——不需要手写测试,typecheck、check:exports、check:published-types、docs:lint 四个门禁就是测试;改的是文档表或 demo——docs:lint 三脚本自动核对。倒过来记也成立:如果发现自己在 jsdom 里模拟 pointer 几何或在 e2e 里断言内部类名哈希,那一定是选错了层。这套分层判断与《06-部署运行》第 8 节的「每层都有存在理由」互相印证,可以把「测试写在哪」从团队默契降级为机械规则——这正是单人仓 + AI 协作者场景最需要的那种规则。
这套质量体系的独特之处不在覆盖率数字,而在「每一层测试都有明确的存在理由」:单测管逻辑与 DOM 语义,交互 e2e 只接 jsdom 的三个已知盲区(pointer 命中、合成事件顺序、transitionend),截图 e2e 只管视觉回归,docs:lint 把文档与类型锁死,check:exports/check:published-types 把发布面锁死。缺的不是哲学而是覆盖的均匀度——37 个组件、54 个 hook 的空白清单已经列在案,第 13 篇会把它并入技术债路线图按优先级消化。