如何针对自定义组件(实现ControlValueAccessor并继承父表单控件)进行Angular单元测试

8

我创建了一个自定义的可搜索下拉框。对于这个 Angular 组件,它实现了 ControlValueAccessor 并获得了 NG_VALUE_ACCESSOR、ControlContainer 提供程序。因此,该组件可以成为父表单的一部分。

为了测试,我已经模拟了 ControlContainer 的提供程序。我还在网上搜索了如何模拟 NG_VALUE_ACCESSOR,但没有找到任何能够起作用的方法。

searchable-dropdown.component.ts:

import {
  Component,
  OnInit,
  ViewChild,
  HostListener,
  ElementRef,
  Input,
  forwardRef,
  SkipSelf,
  OnChanges,
  SimpleChanges
} from '@angular/core';
import { ControlContainer, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

import { controlContainerFactory } from './helper';

@Component({
  selector: 'app-searchable-dropdown',
  templateUrl: './searchable-dropdown.component.html',
  styleUrls: ['./searchable-dropdown.component.css'],
  providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => SearchableDropdownComponent) }],
  viewProviders: [
    { provide: ControlContainer, useFactory: controlContainerFactory, deps: [[new SkipSelf(), ControlContainer]] }
  ]
})
export class SearchableDropdownComponent implements OnInit, ControlValueAccessor, OnChanges {
  @Input() formControlName: string;
  @Input() label: string;
  @Input() placeholder: string;
  @Input() options: string[];

  SearchText = '';
  filterOptions: string[];
  propagateChange: any;
  propagateTouch: any;

  dropDownDirection = 'down';
  preventDropdown = false;
  canShowDropdown = false;
  @ViewChild('container') container: ElementRef;
  @ViewChild('input') input: ElementRef;

  constructor() {}

  get searchText(): string {
    return this.SearchText;
  }

  set searchText(value: string) {
    this.SearchText = value;
    this.propagateChange(value);
  }

  ngOnInit() {
    this.filterOptions = this.options;
  }

  ngOnChanges(changes: SimpleChanges): void {
    const { options } = changes;
    if (options) {
      this.options = options.currentValue;
      this.filterOptions = this.options;
    }
  }

  writeValue(obj: any): void {
    if (obj) {
      this.searchText = obj;
      this.filterOptions = this.options;
      this.preventDropdown = true;
      setTimeout(() => {
        this.preventDropdown = false;
      }, 0);
    }
  }

  registerOnChange(fn: any): void {
    this.propagateChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.propagateTouch = fn;
  }

  @HostListener('document:keydown.escape', ['$event']) hideDropdown(): void {
    this.canShowDropdown = false;
  }

  @HostListener('document:click', ['$event.target']) onClick(targetElement: HTMLElement): void {
    if (this.input.nativeElement.contains(targetElement)) {
      this.showDropdown();
    } else {
      this.hideDropdown();
    }
  }

  @HostListener('window:scroll', []) onScroll(): void {
    if (window.innerHeight < this.container.nativeElement.getBoundingClientRect().top + 280) {
      this.dropDownDirection = 'up';
    } else {
      this.dropDownDirection = 'down';
    }
  }

  showDropdown() {
    this.canShowDropdown = !this.preventDropdown;
  }

  onKeyDown(event: KeyboardEvent) {
    if (event.key === 'Enter') {
      const result = this.filter(this.options, this.searchText);
      this.searchText = result.length ? result[0] : '';
      this.filterOptions = this.options;
      this.canShowDropdown = false;
    }
  }

  onSearch(value: string): void {
    this.searchText = value;
    this.filterOptions = this.filter(this.options, value);
  }

  filter = (array: string[], text: string) => {
    return array.filter(option =>
      option.match(
        new RegExp(
          `.*${text
            .replace(/[\W\s]/gi, '')
            .split('')
            .join('.*')}.*`,
          'i'
        )
      )
    );
  }

  pickOption(option: string) {
    this.searchText = option;
    this.filterOptions = this.options;
    setTimeout(() => {
      this.hideDropdown();
    }, 0);
  }
}

searchable-dropdown.component.html:

<div class="search">
  <label>
    {{ label }}:
    <div #container>
      <div class="input">
        <input
          #input
          type="text"
          [placeholder]="placeholder"
          [value]="searchText"
          [formControlName]="formControlName"
          (focus)="showDropdown()"
          (keydown)="onKeyDown($event)"
          (ngModelChange)="onSearch($event)"
        />
        <i
          class="anticon"
          [ngClass]="canShowDropdown ? 'anticon-up' : 'anticon-down'"
          (click)="canShowDropdown = !canShowDropdown"
        ></i>
      </div>
      <ul *ngIf="canShowDropdown" [ngClass]="dropDownDirection">
        <li *ngFor="let option of filterOptions" (click)="pickOption(option)">
          {{ option }}
        </li>
      </ul>
    </div>
  </label>
</div>

searchable-dropdown.component.spec.ts

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA, forwardRef } from '@angular/core';
import {
  FormsModule,
  ReactiveFormsModule,
  ControlContainer,
  FormGroupDirective,
  FormGroup,
  FormControl,
  NG_VALUE_ACCESSOR
} from '@angular/forms';
// import { By } from '@angular/platform-browser';

import { SearchableDropdownComponent } from './searchable-dropdown.component';

fdescribe('SearchableDropdownComponent', () => {
  let component: SearchableDropdownComponent;
  let fixture: ComponentFixture<SearchableDropdownComponent>;
  let element: HTMLElement;

  const formGroup = new FormGroup({ test: new FormControl('') });
  const formGroupDirective = new FormGroupDirective([], []);
  formGroupDirective.form = formGroup;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [FormsModule, ReactiveFormsModule],
      declarations: [SearchableDropdownComponent],
      providers: [
        {
          provide: ControlContainer,
          useValue: formGroupDirective
        }
        // ,
        // { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => SearchableDropdownComponent) }
      ],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(SearchableDropdownComponent);
    component = fixture.componentInstance;
    component.formControlName = 'test';
    fixture.detectChanges();

    element = fixture.debugElement.nativeElement;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('label innerText should match', () => {
    component.label = 'test label name';
    fixture.detectChanges();
    let label = element.querySelector('label');
    expect(label.innerText).toContain('test label name');

    component.label = 'new test label name';
    fixture.detectChanges();
    label = element.querySelector('label');
    expect(label.innerText).toContain('new test label name');
  });

  it('input placeholder should match', () => {
    component.placeholder = 'test placeholder';
    fixture.detectChanges();
    let input = element.querySelector('input');
    expect(input.getAttribute('placeholder')).toBe('test placeholder');

    component.placeholder = 'new test placeholder';
    fixture.detectChanges();
    input = element.querySelector('input');
    expect(input.getAttribute('placeholder')).toBe('new test placeholder');
  });

  it('dropdown should match with options', () => {
    component.options = ['1', '2', '3'];
    component.canShowDropdown = true;
    component.ngOnInit();
    fixture.detectChanges();
    let ul = element.querySelector('ul');
    expect(ul.children.length).toBe(3);

    component.options = ['1', '2'];
    // component.onSearch('');
    fixture.detectChanges();
    ul = element.querySelector('ul');
    console.log(ul.children);
  });

  it('input change should trigger back to form control', () => {
    // component.searchText = 'new search';
    console.log(formGroup.value);
  });
});

如何正确地测试使用 NG_VALUE_ACCESSOR 提供的组件?

我应该如何模拟 NG_VALUE_ACCESSOR 的提供者?这样,当组件的 searchText 发生更改时,它会触发提供的模拟控件容器上的变化?


对我来说,我的测试从未涵盖过这一行 { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => SearchableDropdownComponent) }。你能模拟提供者吗?我相信那也是我的解决方案。 - lordUhuru
我最终使用了 schemas: [NO_ERRORS_SCHEMA],并且像下面这样进行模拟:formGroup = new FormGroup({ test: new FormControl('') }); formGroupDirective = new FormGroupDirective([], []); formGroupDirective.form = formGroup; ... providers: [ { provide: ControlContainer, useValue: formGroupDirective } ] - Jiahua Zhang
1个回答

4
为了覆盖forwardRef函数,只需手动调用注入器获取器,如下所示:
beforeEach(() => {
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR);
    fixture.detectChanges();
});

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接