Fuel UX 树组件无障碍改造的测试策略:跳过的单元测试清单与补全指引

原创2026-10-14 12:50:351,862 阅读
文章标签:前端UI组件

Fuel UX 树组件无障碍改造的测试策略:跳过的单元测试清单与补全指引

本文以仓库中的 test/SKIPPED_TESTS.md 为核心依据,完整梳理 Fuel UX 树(tree)组件在无障碍(accessibility)改造后单元测试的真实状态:哪些测试已经“烘焙”完成、哪些以 QUnit.skip 形式保留了预期行为定义但尚未补全,并深入结合 js/tree.js 源码与 test/tree-tests 测试模块,给出每一条跳过测试背后的实现原理与补全思路。读完本文,你将能看懂这套树组件键盘导航测试的设计意图,并知道在何种情况下、以何种方式把剩余测试补齐。

背景:无障碍改造遗留的测试债

Fuel UX 是一个基于 jQuery + Bootstrap 的开源 UI 框架(2019 年 3 月后仓库已归档为只读状态,不再维护)。在其树组件的一次大规模无障碍改造中,作者坦承:当时的时间不允许把所有单元测试都“完全烘焙”(fully-baked)。

这句话记录在 test/SKIPPED_TESTS.md 中,是理解整套测试体系的关键起点:

  • 改造过程中,作者把所有“能想到的合理测试用例”都预先定义了下来,用 QUnit.skip 占位,目的不是偷懒,而是用测试用例本身来记录树组件的预期功能(intended functionality);
  • 一旦将来有人再次改动树组件(原文档也承认,鉴于 Fuel UX 当时的项目状态,这本身是个很大的问号),这些被影响的旧功能就应当“在合理的前提下”被完整的单元测试覆盖;
  • 因此,SKIPPED_TESTS.md 实际上是一份**“待补全测试的债务清单 + 预期行为说明书”**,而不是普通的说明文档。

这份文档虽然篇幅极短,但它精确地指向了 test/tree-tests/ 目录下十几个测试模块的真实完成度。下面我们先看测试体系是如何组织的,再逐条分析被跳过的测试。

树组件测试体系的整体架构

测试入口与模块组织

树组件的全部 QUnit 测试由 test/tree-test.js 统一装配。该文件定义了一个顶层模块 Fuel UX Tree,通过 beforeEach: require('./tree-tests/helpers').setup 为每个用例注入统一的测试夹具(fixture),然后按主题分组引入各测试模块:

测试夹具与按键模拟

所有用例共享同一套夹具 test/tree-tests/helpers.js:它构建了带调用上限的数据源 dataSource、纯文本数据源 textDataSource,并从 test/markup/tree-markup.html 载入三个树实例(#MyTree、#MyTree2、#MyTreeSelectableFolder)。该 markup 中每个节点都带有完整的 ARIA 属性(role="tree"、role="treeitem"、aria-expanded、aria-selected、aria-activedescendant),是键盘导航测试的 DOM 基础。

按键事件的构造集中在 test/helpers.js:KEYMAP 定义了 tab: 9、enter: 13、space: 32、end: 35、home: 36、left: 37、up: 38、right: 39、down: 40,getKeyDown(which, target) 会构造一个带 which、keyCode 及 originalEvent.target 的 jQuery keydown 事件。所有键盘测试(无论已完成的还是跳过的)都围绕 document.activeElement 与 keyboardNavigated.fu.tree 事件断言焦点去向,这一模式是补全测试时最值得复用的模板。

如何运行这些测试

测试通过 Grunt 驱动 QUnit 在真实浏览器页面中运行。入口配置见 grunt/config/qunit.js,测试任务编排见 grunt/tasks/test.js:

  • grunt test:依次执行 jshint、启动 connect:testServer(端口 9000)、qunit:noMoment、qunit:globals、test-dist 与 htmllint,是提交 PR 前的标准流程;
  • grunt unittest:只做 dist 构建后的单元测试;
  • 测试宿主页面 test/tests.html 通过 ?testdist=true、?no-moment=true、?jquery=1.9.1 等 URL 参数切换被测代码与依赖版本;
  • Gruntfile.js 中的 allTestUrls 会依次用 jQuery 2.1.0、1.11.0、1.9.1、浏览器全局变量模式、无 moment 模式、代码覆盖率模式对 dist 产物跑全套用例。

需要说明的是,QUnit.skip 标记的用例在运行时不会执行断言,只会在 QUnit 报告中以 skip 状态呈现,因此上述任务默认不会因这些未完成用例而失败——它们是一份“静默的待办清单”。

已完成测试覆盖:无障碍改造中已“烘焙”的部分

在逐个展开跳过测试之前,先明确哪些行为已经有了完整断言,这能帮读者理解“跳过”与“完成”之间的边界。以下均来自 test/tree-tests/ 下可实际执行的 QUnit.test:

  • 默认值(defaults.js):multiSelect 默认 false、cacheItems 默认 true、folderSelect 默认 true、itemSelect 默认 true、disclosuresUpperLimit 默认 0,且 dataSource 默认存在——这些与 js/tree.js 的 $.fn.tree.defaults 一一对应;
  • 数据源调用(calls-datasource.js):断言渲染前 dataSource 必然被调用,且收到 options 对象与 callback 函数;
  • 初始填充(is-populated.js):初始化后树中出现 4 个文件夹、4 个条目、1 个 overflow 节点(数量常量定义在 test/tree-tests/helpers.js 的 constants 中);
  • 选择与 aria-selected(single-select.js 与 multi-select.js):单选时新选中会替换旧选中、多选时互不影响,且选中/取消选中会同步维护 aria-selected="true"/"false" 属性;
  • 禁用选择:itemSelect: false 时 selectItem 无效(disable-select-items.js),folderSelect: false 时 selectFolder 无效(disable-select-folders.js);
  • 展开/折叠(folders-open-close.js):discloseFolder、closeFolder、toggleFolder 对 tree-open 类与幂等性的完整断言;
  • 批量展开:discloseVisible(disclose-visible.js)与带 disclosuresUpperLimit 上限、exceededDisclosuresLimit.fu.tree 事件、ignore-disclosures-limit 机制的 discloseAll(disclose-all.js);
  • 刷新(refresh.js):refreshFolder 清空并重新填充子节点;
  • 焦点管理(重点已覆盖部分)(focus-module.js):
    • focusIn:无选中时聚焦第一个可见分支;有选中时聚焦选中分支;
    • fixFocusability:聚焦分支 tabindex=0,其余分支 tabindex=-1;
    • setFocus:把树的 aria-activedescendant 设为聚焦分支的 id,并触发 setFocus.fu.tree 事件;
  • 方向键的基础场景(left-key-module.js、up-key-module.js、down-key-module.js):已覆盖“在兄弟节点间移动焦点”“已展开分支上按左键收起”“打开的空分支上按上下键跳过”等场景;
  • 别名与销毁(get-value-alias.js、is-destroyed.js):getValue 等价于 selectedItems;destroy 返回原始 markup 字符串并移除 DOM。

被跳过的测试完整清单与预期行为

根据对 test/tree-tests/ 目录的逐文件检索(QUnit.skip 共出现 16 处),下面按模块列出全部跳过用例及其预期的键盘行为。这些描述本身就是文档作者为“预期功能”留下的契约,值得逐条对照 js/tree.js 的 navigateTree 实现来理解。

1. tab 键索引(tab-indexes.js,4 条全部跳过)

跳过用例 预期行为
are all set to -1 on tree load 树加载后,所有子节点 tabindex 均为 -1
are set to 1 for selected items 选中条目的 tabindex 被设为 1
are all set to -1 on new item selection 新条目被选中后,其余条目 tabindex 全部恢复 -1
root tree element is set to -1 on item selection 条目被选中时,根 <ul> 元素 tabindex 变为 -1

这里需要注意:文档注释中写的是 -1 / 1,而 focus-module.js 中已完成的 fixFocusability 测试断言的是聚焦分支 tabindex=0、其余 -1。从 js/tree.js 的 fixFocusability 实现看,其设计意图是“整个树(含根元素)中同一时刻只有一个元素拥有 tabindex=0”。因此补全这 4 条时,应结合该实现明确“选中项”与“焦点项”两个概念,避免与现有 fixFocusability 断言冲突(源码注释本身也承认这套 tabindex 方案“唯一可能的进一步改进”是监听失焦后重置,目前有意保留最后聚焦元素的 tabindex=0)。

2. 下方向键(down-key-module.js,2 条跳过)

  • when focus is on last focusable child of parent, moves focus out of parent onto first focusable sibling of parent:焦点位于父节点最后一个可聚焦子节点时,按↓应移出父节点,落到父节点的第一个可聚焦兄弟节点上;
  • when focus is on open branch, moves focus into open branch onto first focusable child:焦点在已展开的分支上时,按↓应移入该分支的第一个可聚焦子节点。

对照 js/tree.js 的 case 40(down)实现:先尝试 targetNode.find('li:not(".hidden"):first')(即“当前分支若展开则进入其首个子节点”),失败则找 nextAll 兄弟,再失败则回溯到父节点的兄弟。可见第一条跳过用例涉及“子节点序列末尾的向上回溯逻辑”,第二条则正是 case 40 前半段的行为,二者都已实现于源码,只是缺少断言。

3. 上方向键(up-key-module.js,2 条跳过)

  • when focus is below open sibling, moves focus into last focusable child of sibling:焦点在某个已展开兄弟节点之下时,按↑应进入该兄弟节点的最后一个可聚焦子节点;
  • when focus is in first child of open branch, moves focus onto parent:焦点位于已展开分支的第一个子节点时,按↑应把焦点移回父节点。

对照 js/tree.js 的 case 38(up)实现:先取上一个可见 li,若其 tree-open 则取它最后一个可见子节点;若没有上一个兄弟则回退到父节点。两条跳过用例恰好分别对应这两个分支逻辑。

4. 空格键(space-key-module.js,3 条全部跳过)

  • when focus is on selectable node, select node:焦点在可选中节点上时,按空格执行选中;
  • when focus is on non-selectable, disclose-able node, toggles node:焦点在不可选中但可展开的节点上时,按空格切换展开/收起;
  • when focus is on non-selectable, non-disclose-able node, clicks on node (this is probably "loading..."):焦点在既不可选中也不可展开的节点(如“Loading…”溢出节点)上时,按空格等同于点击该节点。

这三条覆盖了 js/tree.js 中 case 13/32(enter/space)的完整决策树:文件夹可选中则 selectFolder,否则 toggleFolder;条目则 selectItem;其余节点走 click() 并配合 loaded.fu.tree 把焦点移到新加载的首个节点。

5. Home / End 键(home-key-module.js、end-key-module.js,各 1 条跳过)

  • Home:moves focus to first selectable node in tree —— 焦点移到树中第一个可选中节点;
  • End:moves focus to last selectable node in tree —— 焦点移到树中最后一个可选中节点。

对照实现,js/tree.js 的 case 35(end)直接 setFocus($tree, $tree.find('li:not(".hidden"):last')),case 36(home)同理取 :first。测试描述中的 “selectable” 与源码中 “:not('.hidden')” 的口径差异,正是补全用例时需要明确并固定的语义。

6. 右方向键(right-key-module.js,2 条全部跳过)

  • when branch is closed, discloses branch:焦点在已收起的分支上时,按→展开该分支;
  • when branch is disclosed, focuses into branch:焦点在已展开的分支上时,按→把焦点移入分支内部。

对应 js/tree.js 的 case 39(right):展开态走 focusIn($tree, $targetNode),收起态走 discloseFolder 并监听 disclosed.fu.tree 事件后再报告键盘导航完成。

7. 左方向键(left-key-module.js,1 条跳过)

  • on child node when branch is closed:焦点位于某子节点上,且其所在分支处于收起状态时按←,应将焦点移回该分支(父节点)。

这条跳过用例与同文件中两条已完成的用例(“顶层收起节点上按←焦点不变”“已展开分支上按←收起分支”)形成互补。值得注意的是,作者在该跳过用例的注释里写明了放弃原因:“The following doesn't actually work due to issues with browsers and focus. Gave up due to time constraints.”——即浏览器焦点行为问题导致测试骨架(含 4 个 assert.expect)无法稳定通过,属于技术上尚未解决的遗留项,补全时可能需要对测试夹具或断言方式做调整,而非简单地把 QUnit.skip 改成 QUnit.test。

汇总:16 条跳过测试的分布

测试模块 跳过条数 主题
tab-indexes.js 4 tabindex 全生命周期
space-key-module.js 3 空格键三种节点形态
down-key-module.js 2 ↓ 键边界场景
up-key-module.js 2 ↑ 键边界场景
right-key-module.js 2 → 键展开/进入
home-key-module.js 1 Home 键
end-key-module.js 1 End 键
left-key-module.js 1 ← 键子节点场景

源码层的实现原理:为什么这些测试能“写出预期行为”

跳过测试的价值在于它们“记录了预期功能”,而这些预期功能在 js/tree.js 中都有真实的实现可对照。理解以下三个机制,就能看懂全部 16 条跳过用例的语义:

键盘导航的统一入口:navigateTree

js/tree.js 的 navigateTree 是树组件 keydown 事件处理器,按 e.which 分发到各方向键与激活键。它有几点设计值得注意:

  • 事件抑制:一旦 isDefaultPrevented() 或 isPropagationStopped() 则直接返回,避免双重处理;
  • “伪 Promise”完成回调:由于 ES5 没有 Promise 且 Fuel UX 不引入 polyfill,作者用“事件监听 + done 回调”模拟异步完成——需要等待 loaded.fu.tree、closed.fu.tree、disclosed.fu.tree 或 selected.fu.tree 的按键操作,会推迟触发 keyboardNavigated.fu.tree,否则立即触发。这就是所有键盘测试都监听 keyboardNavigated.fu.tree 来断言“一次按键完整结束”的原因;
  • 默认分支放行:未处理按键直接 return true 放行,已处理按键则 preventDefault + stopPropagation。

焦点与 tabindex 的三件套:fixFocusability、focusIn、setFocus

  • fixFocusability(js/tree.js):树根 tabindex=-1、所有 li 子节点 tabindex=-1,仅目标分支 tabindex=0。这是“整树单焦点”可访问性模型的核心;
  • focusIn(js/tree.js):优先聚焦 .tree-selected:first,否则聚焦第一个可见节点——对应已完成的 focus-module.js 断言,也是 right 键“进入分支”的落点;
  • setFocus(js/tree.js):执行 fixFocusability、更新根节点 aria-activedescendant、调用 $branch.focus()、触发 setFocus.fu.tree。

三种节点类型与激活语义

从 test/markup/tree-markup.html 的模板可以看到三类节点:tree-branch(文件夹,data-template="treebranch")、tree-item(条目,data-template="treeitem")、tree-overflow(溢出/“加载更多”,data-template="treeoverflow")。空格键的三条跳过用例正是按这三类节点 + “是否可选中/可展开”组合出的决策矩阵,而 js/tree.js 中 foldersSelectable(是否有 tree-folder-select 类)的判断是其中的关键开关。

如何补全这些测试:方法论与可复用模板

如果后续有人再次触碰树组件(这正是 SKIPPED_TESTS.md 预设的触发条件),补全工作可以按以下步骤进行:

  1. 沿用既有夹具与事件模式:所有用例都应复用 test/tree-tests/helpers.js 的 setup(this.$tree、this.dataSource、this.getKeyDown),并在 initialized.fu.tree 之后挂载 keyboardNavigated.fu.tree 监听,参考已完成的 down-key-module.js 用例结构;
  2. 以 QUnit.test 替换 QUnit.skip,补全断言体:跳过用例已保留了名称与注释,将其还原为真实断言即可;对于 left-key-module.js 这类“因浏览器焦点问题放弃”的用例,还需同步验证测试环境(注意 test/tests.js 中为旧 IE 准备了 Event polyfill,说明该套件需兼容较老浏览器);
  3. 以源码为准校准断言口径:例如 Home/End 的描述是 “selectable node”,而源码实现是 li:not('.hidden'),补全时应让测试描述与实现口径一致,或反过来修正实现;
  4. 回归验证:在 test/tests.html 页面中(或通过 grunt test / grunt unittest)确认新用例通过,同时保证已完成的 16 条左右的既有断言不回归;若仓库需重新构建 dist,可参考 grunt/config/qunit.js 中 dist 目标的配置。

结语

SKIPPED_TESTS.md 不是一份“缺陷说明”,而是一份以测试用例形式固化下来的需求契约:树组件无障碍改造的核心机制(tabindex 单焦点模型、aria-activedescendant 同步、六方向键 + Home/End + 空格的全套键盘导航)已经在 js/tree.js 中落地,绝大多数行为也已有可执行的断言;剩余的 16 条跳过用例则集中在“边界场景”与“跨节点焦点迁移”上,其中大部分可直接对照源码补全,仅少数(如左键子节点场景)受浏览器焦点行为限制而需要额外设计。对任何接手维护该组件的开发者而言,这份清单就是一份现成的“测试补全路线图”——这也正是原文档作者写下它的初衷。

登录后查看全文
fuelux