Fuel UX 树组件无障碍改造的测试策略:跳过的单元测试清单与补全指引
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),然后按主题分组引入各测试模块:
- 基础行为:
should be defined on jquery object、should return element; - 默认配置:
should have correct defaults(对应 test/tree-tests/defaults.js); - 数据源与填充:test/tree-tests/calls-datasource.js、test/tree-tests/is-populated.js、test/tree-tests/does-folder-populate.js;
- 选择行为:
Select分组下的 single-select.js 与 multi-select.js,以及禁用选择的 disable-select-items.js、disable-select-folders.js; - 展开/折叠:folders-open-close.js、disclose-visible.js、disclose-all.js;
- 刷新与别名:refresh.js、get-value-alias.js、tree-accepts-name.js;
- 无障碍(Accessibility)分组:这是
SKIPPED_TESTS.md的主角,包含 tab-indexes.js、focus-module.js,以及keyboard navigation子分组下的 left-key-module.js、right-key-module.js、up-key-module.js、down-key-module.js、space-key-module.js、home-key-module.js、end-key-module.js; - 销毁:is-destroyed.js。
测试夹具与按键模拟
所有用例共享同一套夹具 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 预设的触发条件),补全工作可以按以下步骤进行:
- 沿用既有夹具与事件模式:所有用例都应复用 test/tree-tests/helpers.js 的
setup(this.$tree、this.dataSource、this.getKeyDown),并在initialized.fu.tree之后挂载keyboardNavigated.fu.tree监听,参考已完成的 down-key-module.js 用例结构; - 以
QUnit.test替换QUnit.skip,补全断言体:跳过用例已保留了名称与注释,将其还原为真实断言即可;对于 left-key-module.js 这类“因浏览器焦点问题放弃”的用例,还需同步验证测试环境(注意 test/tests.js 中为旧 IE 准备了Eventpolyfill,说明该套件需兼容较老浏览器); - 以源码为准校准断言口径:例如 Home/End 的描述是 “selectable node”,而源码实现是
li:not('.hidden'),补全时应让测试描述与实现口径一致,或反过来修正实现; - 回归验证:在
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 条跳过用例则集中在“边界场景”与“跨节点焦点迁移”上,其中大部分可直接对照源码补全,仅少数(如左键子节点场景)受浏览器焦点行为限制而需要额外设计。对任何接手维护该组件的开发者而言,这份清单就是一份现成的“测试补全路线图”——这也正是原文档作者写下它的初衷。