web-dev-qa-db-fra.com

Comment tester un formulaire FormControl dans Angular2

Ma méthode testée est la suivante:

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

Comme vous pouvez le voir, paymentTerm est un contrôle de formulaire qui renvoie un observable, qui est ensuite souscrit et la valeur de retour est vérifiée.

Je n'arrive pas à trouver de documentation sur les tests unitaires d'un FormControl. Le plus proche que je suis venu est cet article sur les requêtes Mocking Http, qui est un concept similaire car ils retournent Observables mais je ne pense pas qu'il s'applique pleinement.

Pour référence, j'utilise Angular RC5, exécutant des tests avec Karma et le framework est Jasmine.

17
chap

Voyons d'abord quelques problèmes généraux avec le test des tâches asynchrones dans les composants. Lorsque nous testons du code asynchrone dont le test n'a pas le contrôle, nous devons utiliser fakeAsync, car cela nous permettra d'appeler tick(), ce qui rend les actions synchrones lors du test. Par exemple

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

Ce test va échouer car le ngOnInit est appelé, mais l'Observable est asynchrone, donc la valeur n'est pas définie à temps pour le synchronus synchronus appelle dans le test (ie le expect).

Pour contourner ce problème, nous pouvons utiliser les fakeAsync et tick pour forcer le test à attendre que toutes les tâches asynchrones en cours se terminent, le faisant apparaître comme s'il était synchrone.

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

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

Maintenant, le test doit réussir, étant donné qu'il n'y a pas de retard inattendu dans l'abonnement Observable, auquel cas nous pouvons même passer un retard de millisecondes dans l'appel de tick tick(1000).

Ceci (fakeAsync) est une fonctionnalité utile, mais le problème est que lorsque nous utilisons templateUrl dans nos @Component S, il fait un appel XHR, et XHR les appels ne peuvent pas être effectués dans un fakeAsync . Il y a des situations où vous pouvez vous moquer du service pour le rendre synchrone, comme mentionné dans ce post , mais dans certains cas, ce n'est tout simplement pas faisable ou simplement trop difficile. Dans le cas des formulaires, ce n'est tout simplement pas faisable.

Pour cette raison, lorsque je travaille avec des formulaires, j'ai tendance à mettre les modèles dans template au lieu d'un extérieur templateUrl et à diviser le formulaire en composants plus petits s'ils sont vraiment gros (juste pour ne pas avoir de chaîne énorme dans le fichier composant). La seule autre option à laquelle je peux penser est d'utiliser un setTimeout à l'intérieur du test, pour laisser passer l'opération asynchrone. C'est une question de préférence. J'ai simplement décidé d'utiliser les modèles en ligne lorsque je travaille avec des formulaires. Cela brise la cohérence de la structure de mon application, mais je n'aime pas la solution setTimeout.

Maintenant, en ce qui concerne les tests réels des formulaires, la meilleure source que j'ai trouvée était juste de regarder les tests d'intégration de code source . Vous souhaiterez remplacer la balise par la version de Angular que vous utilisez, car la branche principale par défaut peut être différente de la version que vous utilisez.

Voici quelques exemples.

Lorsque vous testez des entrées, vous souhaitez modifier la valeur d'entrée sur nativeElement et envoyer un événement input à l'aide de dispatchEvent. Par exemple

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

Il s'agit d'un test simple tiré du test d'intégration de source. Ci-dessous, il y a plus d'exemples de test, un autre tiré de la source et quelques autres qui ne le sont pas, juste pour montrer d'autres façons qui ne sont pas dans les tests.

Pour votre cas particulier, il semble que vous utilisez le (ngModelChange), Où vous lui affectez l'appel à onPaymentTermChange(). Si tel est le cas, votre implémentation n'a pas beaucoup de sens. (ngModelChange) Va déjà cracher quelque chose lorsque la valeur change, mais vous vous abonnez à chaque fois que le modèle change. Ce que vous devez faire, c'est accepter le paramètre $event Ce qui est émis par l'événement change

(ngModelChange)="onPaymentTermChange($event)"

Vous obtiendrez la nouvelle valeur chaque fois qu'elle change. Il vous suffit donc d'utiliser cette valeur dans votre méthode, au lieu de vous abonner. Le $event Sera la nouvelle valeur.

Si vous voulez utiliser le valueChange sur le FormControl, vous devriez plutôt commencer à l'écouter dans ngOnInit, vous n'êtes donc abonné qu'une seule fois. Vous verrez un exemple ci-dessous. Personnellement, je n'irais pas dans cette voie. J'irais simplement avec la façon dont vous faites, mais au lieu de vous abonner au changement, acceptez simplement la valeur d'événement du changement (comme décrit précédemment).

Voici quelques tests complets

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

MISE À JOUR

En ce qui concerne la première partie de cette réponse sur le comportement asynchrone, j'ai découvert que vous pouvez utiliser fixture.whenStable() qui attendra les tâches asynchrones. Donc, pas besoin d'utiliser uniquement des modèles en ligne

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