Angular 16指令测试:NativeElement的scrollWidth/clientWidth始终为0
Angular指令测试:scrollWidth/clientWidth始终为0的解决方案
问题背景
开发的ShowTooltipIfTruncatedDirective指令在生产环境正常工作,核心逻辑是:
- 根据元素
scrollWidth与clientWidth的大小关系,控制MatTooltip的disabled状态 - 监听窗口
resize事件,实时更新tooltip状态
需要验证三个测试场景:
- 初始化时,若
scrollWidth > clientWidth,matTooltip.disabled为false(内容截断时启用tooltip) - 初始化时,若
scrollWidth < clientWidth,matTooltip.disabled为true(内容未截断时禁用tooltip) - 窗口
resize触发后,matTooltip.disabled状态随元素尺寸变化更新
但使用Angular 16 + Jest测试时,ElementRef.nativeElement的scrollWidth和clientWidth始终为0,尝试替换ElementRef实例、触发变更检测、调整CSS均无效,同时疑问为何无法替换ElementRef实例。
原测试代码:
import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ShowTooltipIfTruncatedDirective } from "./show-if-truncated.directive"; import { By } from "@angular/platform-browser"; import { MatTooltip, MatTooltipModule } from "@angular/material/tooltip"; @Component({ template: ` <div style="width: 200px; height:200px; display:block"> <div style="width: 100px" [matTooltip]="'tooltip text'" appShowTooltipIfTruncated>Test</div> </div> ` }) class HostComponent {} describe('ShowTooltipIfTruncatedDirective', () => { let fixture: ComponentFixture<HostComponent>; let directive: ShowTooltipIfTruncatedDirective; let matTooltip: MatTooltip; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [HostComponent], imports: [ShowTooltipIfTruncatedDirective, MatTooltipModule], }).compileComponents(); fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); directive = fixture.debugElement.query(By.directive(ShowTooltipIfTruncatedDirective)).componentInstance; matTooltip = fixture.debugElement.query(By.directive(MatTooltip)).componentInstance; }); it('should create', () => { expect(directive).toBeTruthy(); }) it('should disable the tooltip on window width changes', () => { expect(matTooltip.disabled).toEqual(false); window.dispatchEvent(new Event('resize', { bubbles: true })) fixture.detectChanges(); expect(matTooltip.disabled).toEqual(true); }) })
核心原因
- Jest模拟DOM的限制:Jest默认使用jsdom模拟浏览器环境,不会计算真实元素的布局尺寸,因此
scrollWidth和clientWidth默认返回0。 - ElementRef替换失败的原因:Angular会自动为指令注入关联DOM元素的
ElementRef实例,手动替换实例的方式无法覆盖Angular的注入逻辑——如果通过HostComponent测试,指令绑定的是宿主组件内实际元素的ElementRef,手动注入的模拟实例会被覆盖;即使单独测试指令,若未正确拦截注入流程,替换也不会生效。
解决办法
方案1:模拟nativeElement的尺寸属性
直接修改指令关联元素的scrollWidth和clientWidth(这两个是只读属性,需用Object.defineProperty模拟),同时建议将指令的判断逻辑封装为公共方法,方便测试时手动触发。
修改后的测试代码:
import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ShowTooltipIfTruncatedDirective } from "./show-if-truncated.directive"; import { By } from "@angular/platform-browser"; import { MatTooltip, MatTooltipModule } from "@angular/material/tooltip"; @Component({ template: ` <div style="width: 200px; height:200px; display:block; overflow:hidden"> <div style="width: 100px; white-space: nowrap; overflow:hidden; text-overflow:ellipsis" [matTooltip]="'tooltip text'" appShowTooltipIfTruncated> {{textContent}} </div> </div> ` }) class HostComponent { textContent = 'Test'; } describe('ShowTooltipIfTruncatedDirective', () => { let fixture: ComponentFixture<HostComponent>; let directive: ShowTooltipIfTruncatedDirective; let matTooltip: MatTooltip; let nativeEl: HTMLElement; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [HostComponent], imports: [ShowTooltipIfTruncatedDirective, MatTooltipModule], }).compileComponents(); fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); const debugEl = fixture.debugElement.query(By.directive(ShowTooltipIfTruncatedDirective)); directive = debugEl.componentInstance; matTooltip = fixture.debugElement.query(By.directive(MatTooltip)).componentInstance; nativeEl = debugEl.nativeElement; }); it('should create', () => { expect(directive).toBeTruthy(); }); it('should enable tooltip when content is truncated on init', () => { // 模拟内容截断:scrollWidth大于clientWidth Object.defineProperty(nativeEl, 'scrollWidth', { value: 200 }); Object.defineProperty(nativeEl, 'clientWidth', { value: 100 }); // 手动触发指令的状态检测逻辑(需确保指令内有checkTooltipState方法) directive['checkTooltipState'](); fixture.detectChanges(); expect(matTooltip.disabled).toBe(false); }); it('should disable tooltip when content is not truncated on init', () => { // 模拟内容未截断:scrollWidth小于clientWidth Object.defineProperty(nativeEl, 'scrollWidth', { value: 80 }); Object.defineProperty(nativeEl, 'clientWidth', { value: 100 }); directive['checkTooltipState'](); fixture.detectChanges(); expect(matTooltip.disabled).toBe(true); }); it('should update tooltip state on window resize', () => { // 初始状态:内容未截断 Object.defineProperty(nativeEl, 'scrollWidth', { value: 80 }); Object.defineProperty(nativeEl, 'clientWidth', { value: 100 }); directive['checkTooltipState'](); fixture.detectChanges(); expect(matTooltip.disabled).toBe(true); // 模拟resize后内容被截断 Object.defineProperty(nativeEl, 'scrollWidth', { value: 200 }); Object.defineProperty(nativeEl, 'clientWidth', { value: 100 }); window.dispatchEvent(new Event('resize')); fixture.detectChanges(); expect(matTooltip.disabled).toBe(false); }); })
方案2:使用真实浏览器环境测试(可选)
如果需要验证真实DOM下的行为,可使用Cypress、Playwright等E2E测试工具,它们在真实浏览器中运行,能正确计算元素的布局尺寸。
关键提示
- 指令内部需将
scrollWidth/clientWidth的判断逻辑封装为可调用的方法(比如checkTooltipState),避免依赖生命周期钩子的触发时机,方便测试时手动调用。 - 若要单独测试指令(不使用HostComponent),可通过
TestBed.overrideProvider注入模拟的ElementRef:
await TestBed.configureTestingModule({ imports: [MatTooltipModule], providers: [ ShowTooltipIfTruncatedDirective, { provide: ElementRef, useValue: new ElementRef({ scrollWidth: 200, clientWidth: 100, addEventListener: jest.fn(), removeEventListener: jest.fn() }) }, { provide: MatTooltip, useValue: { disabled: false } } ] }).compileComponents();
内容的提问来源于stack exchange,提问作者IDK4real
相关产品推荐
相关产品推荐

