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

Angular组件测试:NgbDatepicker通过但报Popper相关split错误

Angular ng-bootstrap日期选择器测试报错解决

问题描述

开发了一个包含ng-bootstrap日期选择器的Angular独立组件,点击按钮切换日期选择器弹窗,选择日期后触发事件输出。组件代码如下:

组件模板

<button
  type="button"
  class="btn btn-outline-secondary"
  aria-label="Select Week Ending Date"
  id="week-ending-btn"
  (click)="picker.toggle()"
>
  <i class="bi bi-calendar3"></i>
  Select Week Ending
</button>

<input
  #picker="ngbDatepicker"
  type="hidden"
  readonly
  ngbDatepicker
  navigation="arrows"
  positionTarget="#week-ending-btn"
  [(ngModel)]="weekEndingModel"
  (dateSelect)="onDateSelection($event, true)"
/>

组件类

@Component({
  selector: 'app-week-ending-selector',
  standalone: true,
  imports: [FormsModule, NgbDatepickerModule],
  templateUrl: './week-ending-selector.component.html',
})
export class WeekEndingSelectorComponent implements OnInit {
  private readonly dateService = inject(DateService);

  @Input() public date?: string;
  @Output() public readonly dateChanged = new EventEmitter<IWeekEndingSelectorChangeEvent>();

  public weekEndingModel!: NgbDateStruct;
  
   public ngOnInit(): void {
    this.weekEndingModel = this.dateService.dateToNgbDate(this.dateService.getCurrentWeekEndingDate());
    this.onDateSelection(this.weekEndingModel, false);
  }

  public onDateSelection(date: NgbDateStruct, shouldEmitEvent: boolean): void {
    const dateAsIso = this.dateService.ngbDateToIso(date);
    const weekEnding = this.dateService.getWeekEndingDate(dateAsIso);

    this.weekEndingFriendly = this.dateService.formatDateString(weekEnding, 'friendlyShort');
    if (shouldEmitEvent) {
      this.dateChanged.emit({
        friendly: this.weekEndingFriendly,
        route: this.dateService.formatDateString(weekEnding, 'isoShort'),
        year: weekEnding.getFullYear(),
        date: weekEnding,
      });
    }
  }
}

测试代码

it('should emit the value for the selected date', () => {
  spyOn(component.dateChanged, 'emit');

  const thisWeek = getCurrentWeekEndingDate(); //a test helper function
  component.date = getCurrentWeekEndingDate(true);

  fixture.detectChanges(); //triggers ngOnInit

  //Click to open the date picker
  fixture.debugElement.query(By.css('#week-ending-btn')).click();
  fixture.detectChanges();

  //This will click on the last day of the first week in the calendar grid
  const day = fixture.debugElement.query(By.css('ngb-datepicker .ngb-dp-day:nth-child(7)'));
  day.click();
  fixture.detectChanges();

  //A bit of a hack, but we can force-parse the date from a string on an attribute
  const dateStr = day.getAttribute('aria-label')?.toString() ?? '';
  const expectedDate = new Date(dateStr);
  expectedDate.setHours(23);
  expectedDate.setMinutes(59);
  expectedDate.setSeconds(59);
  expectedDate.setMilliseconds(999);

  expect(component.dateChanged.emit).toHaveBeenCalledOnceWith({
    friendly: formatDateAsFriendly(expectedDate),
    route: formatDateAsIso(expectedDate),
    year: expectedDate.getFullYear(),
    date: expectedDate,
  });
});

测试执行通过,但控制台抛出错误:

'ERROR', TypeError: Cannot read properties of undefined (reading 'split')
TypeError: Cannot read properties of undefined (reading 'split')
    at getPopperOptions (http://localhost:9876/_karma_webpack_/webpack:/node_modules/@ng-bootstrap/ng-bootstrap/fesm2022/ng-bootstrap.mjs:3734:21)
    at Object.createPopper (http://localhost:9876/_karma_webpack_/webpack:/node_modules/@ng-bootstrap/ng-bootstrap/fesm2022/ng-bootstrap.mjs:3815:57)
    at apply (http://localhost:9876/_karma_webpack_/webpack:/node_modules/@ng-bootstrap/ng-bootstrap/fesm2022/ng-bootstrap.mjs:4118:39)
    at _ZoneDelegate.invoke (http://localhost:9876/_karma_webpack_/webpack:/node_modules/zone.js/fesm2015/zone.js:369:28)
    at ProxyZoneSpec.onInvoke (http://localhost:9876/_karma_webpack_/webpack:/node_modules/zone.js/fesm2015/zone-testing.js:2081:39)
    at _ZoneDelegate.invoke (http://localhost:9876/_karma_webpack_/webpack:/node_modules/zone.js/fesm2015/zone.js:368:34)
    at ZoneImpl.run (http://localhost:9876/_karma_webpack_/webpack:/node_modules/zone.js/fesm2015/zone.js:111:43)
    at NgZone.runOutsideAngular (http://localhost:9876/_karma_webpack_/webpack:/node_modules/@angular/core/fesm2022/core.mjs:7142:28)
    at NgbInputDatepicker.open (http://localhost:9876/_karma_webpack_/webpack:/node_modules/@ng-bootstrap/ng-bootstrap/fesm2022/ng-bootstrap.mjs:4116:26)
    at NgbInputDatepicker.toggle (http://localhost:9876/_karma_webpack_/webpack:/node_modules/@ng-bootstrap/ng-bootstrap/fesm2022/ng-bootstrap.mjs

问题原因

这个错误是因为ng-bootstrap日期选择器在测试环境中调用Popper.js进行弹窗定位时,无法正确获取positionTarget指定的#week-ending-btn元素的位置信息,导致内部处理位置属性时触发undefined.split()报错。测试环境的DOM异步渲染特性,可能导致点击按钮时目标元素的位置数据尚未完全初始化。

解决方法

方案1:跳过弹窗渲染,直接测试核心逻辑

不需要实际打开弹窗,直接调用组件的onDateSelection方法传入模拟日期结构,绕过弹窗定位逻辑:

it('should emit the value for the selected date', () => {
  spyOn(component.dateChanged, 'emit');
  
  // 构造模拟的NgbDateStruct对象
  const testDate: NgbDateStruct = { year: 2024, month: 5, day: 31 };
  // 直接调用日期选择处理方法
  component.onDateSelection(testDate, true);
  
  const expectedDate = new Date(2024, 4, 31); // JavaScript中月份为0-based
  expectedDate.setHours(23, 59, 59, 999);
  
  expect(component.dateChanged.emit).toHaveBeenCalledOnceWith({
    friendly: formatDateAsFriendly(expectedDate),
    route: formatDateAsIso(expectedDate),
    year: expectedDate.getFullYear(),
    date: expectedDate,
  });
});

方案2:等待DOM渲染稳定后操作

在测试中加入await fixture.whenStable(),等待Angular异步渲染完成,确保元素位置信息初始化:

it('should emit the value for the selected date', async () => {
  spyOn(component.dateChanged, 'emit');

  const thisWeek = getCurrentWeekEndingDate();
  component.date = getCurrentWeekEndingDate(true);

  fixture.detectChanges();
  await fixture.whenStable(); // 等待组件初始化完成

  // 点击按钮打开弹窗
  fixture.debugElement.query(By.css('#week-ending-btn')).click();
  fixture.detectChanges();
  await fixture.whenStable(); // 等待弹窗渲染完成

  const day = fixture.debugElement.query(By.css('ngb-datepicker .ngb-dp-day:nth-child(7)'));
  day.click();
  fixture.detectChanges();

  const dateStr = day.getAttribute('aria-label')?.toString() ?? '';
  const expectedDate = new Date(dateStr);
  expectedDate.setHours(23, 59, 59, 999);

  expect(component.dateChanged.emit).toHaveBeenCalledOnceWith({
    friendly: formatDateAsFriendly(expectedDate),
    route: formatDateAsIso(expectedDate),
    year: expectedDate.getFullYear(),
    date: expectedDate,
  });
});

方案3:Mock Popper定位逻辑

通过spyOn禁用日期选择器的真实弹窗打开逻辑,避免执行Popper定位代码:

import { NgbInputDatepicker } from '@ng-bootstrap/ng-bootstrap';

// 在测试的beforeEach中添加mock逻辑
beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [WeekEndingSelectorComponent]
  }).compileComponents();
  
  fixture = TestBed.createComponent(WeekEndingSelectorComponent);
  component = fixture.componentInstance;
  
  // 获取模板中的picker实例
  const pickerDebugEl = fixture.debugElement.query(By.css('input[ngbDatepicker]'));
  const pickerInstance = pickerDebugEl.componentInstance as NgbInputDatepicker;
  
  // Mock open方法,跳过真实的Popper初始化
  spyOn(pickerInstance, 'open').and.callFake(() => {
    pickerInstance.isOpen = true;
    fixture.detectChanges();
  });
});

it('should emit the value for the selected date', () => {
  spyOn(component.dateChanged, 'emit');

  component.date = getCurrentWeekEndingDate(true);
  fixture.detectChanges();

  fixture.debugElement.query(By.css('#week-ending-btn')).click();
  fixture.detectChanges();

  const day = fixture.debugElement.query(By.css('ngb-datepicker .ngb-dp-day:nth-child(7)'));
  day.click();
  fixture.detectChanges();

  const dateStr = day.getAttribute('aria-label')?.toString() ?? '';
  const expectedDate = new Date(dateStr);
  expectedDate.setHours(23, 59, 59, 999);

  expect(component.dateChanged.emit).toHaveBeenCalledOnceWith({
    friendly: formatDateAsFriendly(expectedDate),
    route: formatDateAsIso(expectedDate),
    year: expectedDate.getFullYear(),
    date: expectedDate,
  });
});

内容的提问来源于stack exchange,提问作者Chris Barr

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 01:54:53