Angular Material Timepicker 测试 Harness 完整指南:从 API Golden 报告到源码级实践
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
本指南以 Angular Material 仓库中@angular/material_timepicker_testing包的 API 报告(goldens/material/timepicker/testing/index.api.md)为核心,系统讲解MatTimepickerHarness、MatTimepickerInputHarness、MatTimepickerToggleHarness三个测试 Harness 的公开 API、过滤条件与底层实现。读完本文,你将掌握如何在基于 Testbed 的组件测试中通过 Harness 稳定地打开时间选择器、读取/设置输入值、断言面板状态与选项内容,并理解这些 API 在 src/material/timepicker/testing 目录中的真实源码行为。
一、认识这份文档:API Golden 报告是什么
goldens/material/timepicker/testing/index.api.md是由微软 API Extractor 工具自动生成的 API 报告文件,文件头部明确标注“Do not edit this file”。它的作用是:
- 锁定包对外公开的 API 形态:
@angular/material_timepicker_testing包只能导出报告中列出的这些类、接口与方法,任何对公开 API 的增删改都会破坏 golden 校验,从而在 CI 中被检测出来(仓库根目录的 goldens/BUILD.bazel 负责相关 golden 测试目标); - 作为 API 消费者(开发者)的权威速查表:报告里每一个
@public条目都代表该包对外承诺的稳定接口。
对应地,包的真实源码入口是 src/material/timepicker/testing/public-api.ts,它导出了四个文件:
export * from './timepicker-harness'; export * from './timepicker-harness-filters'; export * from './timepicker-input-harness'; export * from './timepicker-toggle-harness';即三个 Harness 类加一个过滤器定义文件,与 golden 报告完全一一对应。此外还有包级入口 src/material/timepicker/testing/index.ts 转发public-api,并配套构建目标 src/material/timepicker/testing/BUILD.bazel。
为什么需要测试 Harness?组件测试如果直接操作 DOM(如querySelector),一旦组件内部模板结构或 CSS 类名发生变化,测试就会大面积失效。Harness 将“组件对外暴露的交互语义”与“内部 DOM 实现”解耦——Harness 的hostSelector(宿主选择器)和方法内部的选择逻辑是唯一需要跟随组件实现演进的地方,而测试代码只依赖 Harness 的公开方法。Angular CDK 的 @angular/cdk/testing 提供了ComponentHarness、HarnessPredicate、TestbedHarnessEnvironment等基础设施,三者共同构成这套测试框架的基石。
二、MatTimepickerHarness:时间选择器面板的测试入口
2.1 类签名与宿主选择器
export class MatTimepickerHarness extends ComponentHarness { static hostSelector: string; // 实际值为 'mat-timepicker' static with<T extends MatTimepickerHarness>( this: ComponentHarnessConstructor<T>, options?: TimepickerHarnessFilters, ): HarnessPredicate<T>; isOpen(): Promise<boolean>; getOptions(filters?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptionHarness[]>; selectOption(filters: OptionHarnessFilters): Promise<void>; protected _getPanelSelector(): Promise<string>; }从源码 src/material/timepicker/testing/timepicker-harness.ts 可以看到,其hostSelector为'mat-timepicker',即匹配mat-timepicker元素。与MatDatepickerHarness等同类 Harness 一致,with()静态方法通过new HarnessPredicate(this, options)构造一个按TimepickerHarnessFilters过滤的谓词。
2.2 关键方法的行为细节
isOpen():判断面板是否打开。实现上通过_getPanelSelector()得到面板选择器,再用documentRootLocatorFactory()(文档根定位器)查找对应面板是否存在。由于时间选择器的下拉面板渲染在 Overlay 中、位于组件 DOM 树之外,因此必须从 document root 而不是从 host 内部查找:async isOpen(): Promise<boolean> { const selector = await this._getPanelSelector(); const panel = await this._documentRootLocator.locatorForOptional(selector)(); return panel !== null; }_getPanelSelector():面板通过mat-timepicker-panel-id属性关联到具体的时间选择器实例,选择器形如#<panel-id>:protected async _getPanelSelector(): Promise<string> { return `#${await (await this.host()).getAttribute('mat-timepicker-panel-id')}`; }这解释了输入框 Harness 中
getTimepicker()为何要读取mat-timepicker-id属性来反向定位(详见第三节)。getOptions(filters?):读取面板内的全部选项,返回MatOptionHarness[]。它复用了 Material 核心测试包 src/material/core/testing 中导出的MatOptionHarness(菜单、下拉、自动完成等组件共用同一套 option Harness)。注意两点:- 面板关闭时调用会直接抛错:
Unable to retrieve options for timepicker. Timepicker panel is closed.; - 过滤参数类型为
Omit<OptionHarnessFilters, 'ancestor'>,ancestor由 Harness 内部用面板选择器强制填充,避免调用者把选项范围限定到错误容器:
async getOptions(filters?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptionHarness[]> { if (!(await this.isOpen())) { throw new Error('Unable to retrieve options for timepicker. Timepicker panel is closed.'); } return this._documentRootLocator.locatorForAll( MatOptionHarness.with({ ...(filters || {}), ancestor: await this._getPanelSelector(), } as OptionHarnessFilters), )(); }- 面板关闭时调用会直接抛错:
selectOption(filters):取第一个匹配过滤条件的选项并模拟点击,若无匹配项则抛出Could not find a mat-option matching ...错误:async selectOption(filters: OptionHarnessFilters): Promise<void> { const options = await this.getOptions(filters); if (!options.length) { throw Error(`Could not find a mat-option matching ${JSON.stringify(filters)}`); } await options[0].click(); }
三、MatTimepickerInputHarness:输入框的测试入口
3.1 类签名与宿主选择器
export class MatTimepickerInputHarness extends ComponentHarness { static hostSelector: string; // 实际值为 '.mat-timepicker-input' static with<T extends MatTimepickerInputHarness>( this: ComponentHarnessConstructor<T>, options?: TimepickerInputHarnessFilters, ): HarnessPredicate<T>; isTimepickerOpen(): Promise<boolean>; openTimepicker(): Promise<MatTimepickerHarness>; closeTimepicker(): Promise<void>; getTimepicker(filter?: TimepickerHarnessFilters): Promise<MatTimepickerHarness>; isDisabled(): Promise<boolean>; isRequired(): Promise<boolean>; getValue(): Promise<string>; setValue(newValue: string): Promise<void>; getPlaceholder(): Promise<string>; focus(): Promise<void>; blur(): Promise<void>; isFocused(): Promise<boolean>; }源码 src/material/timepicker/testing/timepicker-input-harness.ts 中,hostSelector为'.mat-timepicker-input',由输入指令MatTimepickerInput在宿主元素上添加(该指令声明在 src/material/timepicker/timepicker-input.ts,宿主选择器为input[matTimepicker])。
3.2 面板联动方法
isTimepickerOpen():读取宿主元素的aria-expanded属性是否为'true',这与组件实际渲染的 ARIA 状态完全一致,无需关心面板 DOM;openTimepicker():若输入框未禁用,则向宿主发送TestKey.DOWN_ARROW按键(与真实用户用键盘打开时间选择器的行为一致),随后返回关联的MatTimepickerHarness:async openTimepicker(): Promise<MatTimepickerHarness> { if (!(await this.isDisabled())) { const host = await this.host(); await host.sendKeys(TestKey.DOWN_ARROW); } return this.getTimepicker(); }注意
TestKey.DOWN_ARROW来自@angular/cdk/testing,是 CDK 测试框架统一封装的按键枚举;closeTimepicker():点击 document root 元素以关闭面板,随后调用forceStabilize()等待关闭动画结束:async closeTimepicker(): Promise<void> { await this._documentRootLocator.rootElement.click(); await this.forceStabilize(); }getTimepicker(filter?):通过宿主上的mat-timepicker-id属性定位到面板的mat-timepicker-panel-id,再在 document root 中查找对应的MatTimepickerHarness。若宿主没有该属性,则抛出Element is not associated with a timepicker:async getTimepicker(filter: TimepickerHarnessFilters = {}): Promise<MatTimepickerHarness> { const host = await this.host(); const timepickerId = await host.getAttribute('mat-timepicker-id'); if (!timepickerId) { throw Error('Element is not associated with a timepicker'); } return this._documentRootLocator.locatorFor( MatTimepickerHarness.with({ ...filter, selector: `[mat-timepicker-panel-id="${timepickerId}"]`, }), )(); }可以看到
mat-timepicker-id与mat-timepicker-panel-id构成了一对内外关联的 ID 契约,这是理解 timepicker 输入框与面板如何绑定的关键源码细节。
3.3 值、状态与焦点方法
getValue()/setValue(newValue):读取/写入原生input的 value。setValue不是直接赋值,而是模拟真实键盘输入——先clear()清空,再通过sendKeys(newValue)逐键输入,从而触发组件的输入事件与表单更新逻辑;若传入空字符串则跳过发送按键(避免产生多余 focus 事件),实现“清空值”的语义:async setValue(newValue: string): Promise<void> { const inputEl = await this.host(); await inputEl.clear(); if (newValue) { await inputEl.sendKeys(newValue); } }getPlaceholder():读取宿主placeholder属性;isDisabled()/isRequired():分别读取宿主disabled/required属性;focus()/blur()/isFocused():聚焦、失焦与聚焦状态断言,用于测试表单 touched/验证触发时机。
3.4 with() 中的自定义过滤
MatTimepickerInputHarness.with()是唯一注册了额外过滤条件的 Harness(另两个 Harness 的with()仅接受基类过滤)。它通过HarnessPredicate.stringMatches支持按value与placeholder过滤,二者均接受字符串或正则表达式:
static with<T extends MatTimepickerInputHarness>( this: ComponentHarnessConstructor<T>, options: TimepickerInputHarnessFilters = {}, ): HarnessPredicate<T> { return new HarnessPredicate(this, options) .addOption('value', options.value, (harness, value) => { return HarnessPredicate.stringMatches(harness.getValue(), value); }) .addOption('placeholder', options.placeholder, (harness, placeholder) => { return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder); }); }四、MatTimepickerToggleHarness:切换按钮的测试入口
export class MatTimepickerToggleHarness extends ComponentHarness { static hostSelector: string; // 实际值为 '.mat-timepicker-toggle' static with(options?: TimepickerToggleHarnessFilters): HarnessPredicate<MatTimepickerToggleHarness>; openTimepicker(): Promise<void>; isTimepickerOpen(): Promise<boolean>; isDisabled(): Promise<boolean>; }源码 src/material/timepicker/testing/timepicker-toggle-harness.ts 的hostSelector为'.mat-timepicker-toggle'(mat-timepicker-toggle组件根元素上的类名)。它的内部结构很简单:通过locatorFor('button')定位到可点击的按钮元素,所有交互都落在该按钮上:
openTimepicker():先检查isTimepickerOpen(),未打开时才点击按钮,避免重复触发;isTimepickerOpen():读取按钮的aria-expanded属性;isDisabled():读取按钮的disabled属性,并通过@angular/cdk/coercion的coerceBooleanProperty归一化为布尔值:async isDisabled(): Promise<boolean> { const button = await this._button(); return coerceBooleanProperty(await button.getAttribute('disabled')); }
与输入框 Harness 通过键盘打开不同,Toggle Harness 模拟的是鼠标点击行为,两条打开路径在真实产品中分别对应键盘用户与鼠标用户,测试时可按需选择。
五、三种过滤器接口:精准定位测试目标
过滤器定义见 src/material/timepicker/testing/timepicker-harness-filters.ts,均继承自 CDK 的BaseHarnessFilters:
export interface TimepickerHarnessFilters extends BaseHarnessFilters {} export interface TimepickerInputHarnessFilters extends BaseHarnessFilters { value?: string | RegExp; placeholder?: string | RegExp; } export interface TimepickerToggleHarnessFilters extends BaseHarnessFilters {}BaseHarnessFilters提供selector(CSS 选择器)、ancestor(祖先元素)等通用过滤字段,可用于区分页面上的多个实例;TimepickerInputHarnessFilters额外支持按value(输入值)和placeholder(占位文本)过滤,匹配规则与HarnessPredicate.stringMatches一致:字符串按子串/精确语义匹配,正则按模式匹配;TimepickerHarnessFilters与TimepickerToggleHarnessFilters目前为空扩展,仅保留基类能力,未来新增过滤维度时不会破坏既有 API 形态。
六、完整测试实践:把 Harness 用起来
仓库自带的测试 src/material/timepicker/testing/timepicker-harness.spec.ts 是学习 Harness 用法的最佳范本。测试使用TestbedHarnessEnvironment.documentRootLoader创建加载器,并通过provideNativeDateAdapter与禁用动画的MATERIAL_ANIMATIONS提供者完成环境配置(前者由 src/material/core 导出):
TestBed.configureTestingModule({ providers: [ provideNativeDateAdapter(), {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ], }); const adapter = TestBed.inject(DateAdapter); adapter.setLocale('en-US'); fixture = TestBed.createComponent(TimepickerHarnessTest); loader = TestbedHarnessEnvironment.documentRootLoader(fixture);测试组件模板展示了 input + timepicker 的标准组合,并以interval="4h"生成 4 小时间隔的选项:
<input id="one" [matTimepicker]="onePicker"> <mat-timepicker #onePicker [interval]="interval()"/> <input id="two" [matTimepicker]="twoPicker"> <mat-timepicker #twoPicker [interval]="interval()"/>几个代表性用例:
1. 加载与关联:loader.getAllHarnesses(MatTimepickerHarness)得到 2 个实例;通过MatTimepickerInputHarness.with({selector: '#one'})拿到指定输入框,再input.getTimepicker()获得其关联的时间选择器。
2. 打开/关闭状态断言:初始timepicker.isOpen()为false,调用input.openTimepicker()后变为true。
3. 读取选项内容:打开面板后timepicker.getOptions(),用parallel并发读取每个 option 的文本,得到['12:00 AM', '4:00 AM', '8:00 AM', '12:00 PM', '4:00 PM', '8:00 PM']——这正是interval="4h"从 0 点到 20 点的六档选项。
4. 关闭状态读选项抛错:面板未打开时getOptions()会以Unable to retrieve options for timepicker. Timepicker panel is closed.被拒绝(用expectAsync(...).toBeRejectedWithError断言)。
5. 选择选项:await timepicker.selectOption({text: '4:00 PM'})后,input.getValue()变为'4:00 PM'且timepicker.isOpen()回到false,验证了选择后自动关闭的行为。
此外 src/material/timepicker/testing/timepicker-input-harness.spec.ts 与 src/material/timepicker/testing/timepicker-toggle-harness.spec.ts 分别覆盖输入框与切换按钮 Harness 的读写值、焦点、禁用态与打开行为,可作为更细粒度的参考。
七、补充:timepicker 主包 API 与 Harness 的关系
Harness 测试的面板与选项,均来自主包 goldens/material/timepicker/index.api.md 中定义的组件:
MatTimepicker<D>:面板组件,关键输入有interval(选项间隔)、options(自定义选项数组)、panelClass、ariaLabel等,输出selected、opened、closed;MatTimepickerInput<D>:输入指令,实现ControlValueAccessor与Validator,输入matTimepicker(必填)、matTimepickerMin、matTimepickerMax、matTimepickerOpenOnClick、disabled,同时提供信号化双向绑定value/valueChange;MatTimepickerToggle<D>:切换按钮组件,for属性(别名timepicker)指向目标 timepicker,支持aria-label、tabIndex、disableRipple,并允许投影[matTimepickerToggleIcon]自定义图标;MatTimepickerConfig与MAT_TIMEPICKER_CONFIG:全局默认配置注入令牌(可配置interval与disableRipple),参见 src/material/timepicker/timepicker.ts。
Harness 中的getOptions()返回的MatOptionHarness对应的正是面板内由options生成的mat-option列表;interval决定默认选项的疏密——这与测试中interval="4h"产生 6 个选项的预期完全吻合。更多关于间隔字符串语法(如'90m'、'1.5 hours')与输入验证(matTimepickerParse、matTimepickerMin/matTimepickerMax错误)的说明,可参考官方文档 src/material/timepicker/timepicker.md。
八、最佳实践小结
- 优先使用 Harness 而非原生 DOM 查询:测试只依赖
with()、getValue()、openTimepicker()等语义化方法,组件模板重构不会破坏测试; - 区分两种打开路径:键盘场景用
MatTimepickerInputHarness.openTimepicker()(发送 Down Arrow),鼠标场景用MatTimepickerToggleHarness.openTimepicker()(点击按钮); - 记住面板关闭约束:
getOptions()与selectOption()都要求面板处于打开状态,测试中应先openTimepicker();关闭后如需等待动画完成再断言,可用closeTimepicker()内部的forceStabilize语义或显式await fixture.whenStable(); - 善用过滤器区分多实例:同一页面存在多个 timepicker 时,通过
with({selector: '#one'})、{value: /^4:/}、{placeholder: 'Start time'}等条件精确定位目标实例; - 以 golden 报告为 API 变更红线:若你的二次开发修改了 testing 包,需同步更新 goldens/material/timepicker/testing/index.api.md 并通过 golden 校验,确保公开 API 的稳定性可追踪。
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考