Как модульное тестирование FormControl в Angular2


Мой тестируемый метод заключается в следующем:

/**
   * Update properties when the applicant changes the payment term value.
   * @return {Mixed} - Either an Array where the first index is a boolean indicating
   *    that selectedPaymentTerm was set, and the second index indicates whether
   *    displayProductValues was called. Or a plain boolean indicating that there was an 
   *    error.
   */
  onPaymentTermChange() {
    this.paymentTerm.valueChanges.subscribe(
      (value) => {
        this.selectedPaymentTerm = value;
        let returnValue = [];
        returnValue.push(true);
        if (this.paymentFrequencyAndRebate) { 
          returnValue.push(true);
          this.displayProductValues();
        } else {
          returnValue.push(false);
        }
        return returnValue;
      },
      (error) => {
        console.warn(error);
        return false;
      }
    )
  }
Как вы можете видеть paymentTerm-это элемент управления формой, который возвращает Observable, который затем подписывается и проверяется возвращаемое значение.

Я не могу найти никакой документации по модульному тестированию FormControl. Ближе всего я подошел к этой статье об издевательствах над Http-запросами, которая является аналогичной концепцией, поскольку они возвращают наблюдаемые объекты, но я не думаю, что это применимо полностью.

Для справки я использую Angular RC5, запуск тестов с кармой и фреймворком - это Жасмин.

1 15

1 ответ:

Сначала перейдем к некоторым общим проблемам тестирования асинхронных задач в компонентах. Когда мы тестируем асинхронный код, который тест не контролирует, мы должны использовать fakeAsync, так как это позволит нам вызвать tick(), что делает действия синхронными при тестировании. Например

class ExampleComponent implements OnInit {
  value;

  ngOnInit() {
    this._service.subscribe(value => {
      this.value = value;
    });
  }
}

it('..', () => {
  const fixture = TestBed.createComponent(ExampleComponent);
  fixture.detectChanges();
  expect(fixture.componentInstance.value).toEqual('some value');
});

Этот тест будет провален при вызове ngOnInit, но наблюдаемое асинхронно, поэтому значение не устанавливается во времени для вызовов synchronus в тесте (т. е. expect).

Чтобы обойти это, мы можем использовать fakeAsync и tick, чтобы заставить тест ждать завершения всех текущих асинхронных задач, делая его похожим на тест, как если бы он был синхронным.

import { fakeAsync, tick } from '@angular/core/testing';

it('..', fakeAsync(() => {
  const fixture = TestBed.createComponent(ExampleComponent);
  fixture.detectChanges();
  tick();
  expect(fixture.componentInstance.value).toEqual('some value');
}));
Теперь тест должен пройти, учитывая отсутствие неожиданной задержки в наблюдаемой подписке, и в этом случае мы можем даже пройти миллисекундную задержку в тиковом вызове tick(1000).

Это (fakeAsync) полезная функция, но проблема в том, что когда мы используем templateUrl в наших @Components, он делает вызов XHR, и вызовы XHR не могут быть сделаны в fakeAsync. Есть ситуации, когда вы можете издеваться над сервисом, чтобы сделать его синхронным, как упоминалось в этом посте, но в некоторых случаях это просто неосуществимо или просто слишком сложно. В случае с формами это просто неосуществимо.

По этой причине, работая с формами, я склонен помещать шаблоны в template вместо внешнего templateUrl и разбивать форму на более мелкие компоненты, если они действительно большие (просто для того, чтобы не иметь огромную строку в файле компонента). Единственный другой вариант, который я могу придумать, - это использовать setTimeout внутри теста, чтобы пропустить асинхронную операцию. Это вопрос предпочтения. Я просто решил использовать встроенные шаблоны при работе с формами. Это нарушает согласованность структуры моего приложения, но мне не нравится решение setTimeout. Что касается фактического тестирования форм, то лучшим источником, который я нашел, было просто посмотреть на тесты интеграции исходного кода . Вы захотите изменить тег на версию Angular, которую вы используете, так как основная ветвь по умолчанию может отличаться от используемой версии. Ниже приводится несколько примеров.

При тестировании входных данных вы хотите изменить входное значение на nativeElement и отправить событие input с помощью dispatchEvent. Например

@Component({
  template: `
    <input type="text" [formControl]="control"/>
  `
})
class FormControlComponent {
  control: FormControl;
}

it('should update the control with new input', () => {
  const fixture = TestBed.createComponent(FormControlComponent);
  const control = new FormControl('old value');
  fixture.componentInstance.control = control;
  fixture.detectChanges();

  const input = fixture.debugElement.query(By.css('input'));
  expect(input.nativeElement.value).toEqual('old value');

  input.nativeElement.value = 'updated value';
  dispatchEvent(input.nativeElement, 'input');

  expect(control.value).toEqual('updated value');
});

Это простой тест, взятый из исходного интеграционного теста. Ниже приведены еще несколько примеров тестов, один из которых взят из источника, а также пара, которые не являются, просто показать другие способы, которые не находятся в тестах.

Для вашего конкретного случая, похоже, вы используете (ngModelChange), где вы назначаете ему вызов onPaymentTermChange(). Если это так, то ваша реализация не имеет большого смысла. (ngModelChange) уже собирается выплюнуть что-то, когда значение изменяется, но вы подписываетесь каждый раз, когда меняется модель. То, что вы должны делать, это принимать параметр $event, который испускается изменением событие

(ngModelChange)="onPaymentTermChange($event)"

Вам будет передаваться новое значение при каждом его изменении. Поэтому просто используйте это значение в своем методе вместо подписки. Новым значением будет $event.

Если вы действительно хотите использовать valueChange на FormControl, вы должны вместо этого начать слушать его в ngOnInit, поэтому вы подписываетесь только один раз. Вы увидите пример ниже. Лично я не пошел бы по этому пути. Я бы просто пошел с тем, как вы делаете, но вместо того, чтобы подписаться на изменение, просто примите значение события из изменения (как описано выше).

Вот некоторые полные тесты

import {
  Component, Directive, EventEmitter,
  Input, Output, forwardRef, OnInit, OnDestroy
} from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser/src/dom/debug/by';
import { getDOM } from '@angular/platform-browser/src/dom/dom_adapter';
import { dispatchEvent } from '@angular/platform-browser/testing/browser_util';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

class ConsoleSpy {
  log = jasmine.createSpy('log');
}

describe('reactive forms: FormControl', () => {
  let consoleSpy;
  let originalConsole;

  beforeEach(() => {
    consoleSpy = new ConsoleSpy();
    originalConsole = window.console;
    (<any>window).console = consoleSpy;

    TestBed.configureTestingModule({
      imports: [ ReactiveFormsModule ],
      declarations: [
        FormControlComponent,
        FormControlNgModelTwoWay,
        FormControlNgModelOnChange,
        FormControlValueChanges
      ]
    });
  });

  afterEach(() => {
    (<any>window).console = originalConsole;
  });

  it('should update the control with new input', () => {
    const fixture = TestBed.createComponent(FormControlComponent);
    const control = new FormControl('old value');
    fixture.componentInstance.control = control;
    fixture.detectChanges();

    const input = fixture.debugElement.query(By.css('input'));
    expect(input.nativeElement.value).toEqual('old value');

    input.nativeElement.value = 'updated value';
    dispatchEvent(input.nativeElement, 'input');

    expect(control.value).toEqual('updated value');
  });

  it('it should update with ngModel two-way', fakeAsync(() => {
    const fixture = TestBed.createComponent(FormControlNgModelTwoWay);
    const control = new FormControl('');
    fixture.componentInstance.control = control;
    fixture.componentInstance.login = 'old value';
    fixture.detectChanges();
    tick();

    const input = fixture.debugElement.query(By.css('input')).nativeElement;
    expect(input.value).toEqual('old value');

    input.value = 'updated value';
    dispatchEvent(input, 'input');
    tick();

    expect(fixture.componentInstance.login).toEqual('updated value');
  }));

  it('it should update with ngModel on-change', fakeAsync(() => {
    const fixture = TestBed.createComponent(FormControlNgModelOnChange);
    const control = new FormControl('');
    fixture.componentInstance.control = control;
    fixture.componentInstance.login = 'old value';
    fixture.detectChanges();
    tick();

    const input = fixture.debugElement.query(By.css('input')).nativeElement;
    expect(input.value).toEqual('old value');

    input.value = 'updated value';
    dispatchEvent(input, 'input');
    tick();

    expect(fixture.componentInstance.login).toEqual('updated value');
    expect(consoleSpy.log).toHaveBeenCalledWith('updated value');
  }));

  it('it should update with valueChanges', fakeAsync(() => {
    const fixture = TestBed.createComponent(FormControlValueChanges);
    fixture.detectChanges();
    tick();

    const input = fixture.debugElement.query(By.css('input')).nativeElement;

    input.value = 'updated value';
    dispatchEvent(input, 'input');
    tick();

    expect(fixture.componentInstance.control.value).toEqual('updated value');
    expect(consoleSpy.log).toHaveBeenCalledWith('updated value');
  }));
});

@Component({
  template: `
    <input type="text" [formControl]="control"/>
  `
})
class FormControlComponent {
  control: FormControl;
}

@Component({
  selector: 'form-control-ng-model',
  template: `
    <input type="text" [formControl]="control" [(ngModel)]="login">
  `
})
class FormControlNgModelTwoWay {
  control: FormControl;
  login: string;
}

@Component({
  template: `
    <input type="text"
           [formControl]="control" 
           [ngModel]="login" 
           (ngModelChange)="onModelChange($event)">
  `
})
class FormControlNgModelOnChange {
  control: FormControl;
  login: string;

  onModelChange(event) {
    this.login = event;
    this._doOtherStuff(event);
  }

  private _doOtherStuff(value) {
    console.log(value);
  }
}

@Component({
  template: `
    <input type="text" [formControl]="control">
  `
})
class FormControlValueChanges implements OnDestroy {
  control: FormControl;
  sub: Subscription;

  constructor() {
    this.control = new FormControl('');
    this.sub = this.control.valueChanges.subscribe(value => {
      this._doOtherStuff(value);
    });
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  private _doOtherStuff(value) {
    console.log(value);
  }
}

Обновление

Что касается первой части этого ответа об асинхронном поведении, я обнаружил, что вы можете использовать fixture.whenStable(), который будет ждать асинхронных задач. Поэтому нет необходимости использовать только встроенные шаблоны
it('', async(() => {
  fixture.whenStable().then(() => {
    // your expectations.
  })
})