You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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);
  })
})

核心原因

  1. Jest模拟DOM的限制:Jest默认使用jsdom模拟浏览器环境,不会计算真实元素的布局尺寸,因此scrollWidth和clientWidth默认返回0。
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 17:07:03