Как unit test a 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. Самое близкое, что я пришел, - это статья о Mocking Http-запросах, которая похожа на концепцию, возвращающую Observables, но я не думаю, что она применима полностью.

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

Ответ 1

UPDATE

Что касается первой части этого ответа об асинхронном поведении, я обнаружил, что вы можете использовать fixture.whenStable(), который будет ожидать асинхронных задач. Поэтому не нужно использовать только встроенные шаблоны

it('', async(() => {
  fixture.whenStable().then(() => {
    // your expectations.
  })
})

Сначала давайте рассмотрим некоторые общие проблемы с тестированием асинхронных задач в компонентах. Когда мы тестируем асинхронный код, который не контролируется тестом, мы должны использовать 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, но Observable является асинхронным, поэтому значение не устанавливается во времени для синхронных вызовов в тесте (т.е. 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');
}));

Теперь тест должен пройти, поскольку в подписке Observable нет неожиданной задержки, и в этом случае мы можем даже пропустить миллисекундную задержку при вызове тика tick(1000).

Это (fakeAsync) является полезной функцией, но проблема в том, что когда мы используем templateUrl в наших @Component, он делает 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);
  }
}