How to unit test a FormControl in Angular2

2020-05-31 03:34发布

My method under test is the following:

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

As you can see paymentTerm is a form control that returns an Observable, which is then subscribed and the return value is checked.

I can't seem to find any documentation on unit testing a FormControl. The closest I have come is this article about Mocking Http requests, which is a similar concept as they are returning Observables but I don't think it applies fully.

For reference I am using Angular RC5, running tests with Karma and the framework is Jasmine.

1条回答
来,给爷笑一个
2楼-- · 2020-05-31 04:15

UPDATE

As far as the first part of this answer about the asynchronous behavior, I've found out that you can use fixture.whenStable() which will wait for asynchronous tasks. So no need to only use inline templates

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

First let we get to some general problems with testing asynchronous tasks in components. When we test asynchronous code that the test is not in control of, we should use fakeAsync, as it will allow us to call tick(), which makes the actions appears synchronous when testing. For example

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

This test is going to fail as the ngOnInit is called, but the Observable is asynchronous, so the value doesn't get set in time for the synchronus calls in the test (i.e. the expect).

To get around this, we can use the fakeAsync and tick to force the test to wait for all current asynchronous tasks to finish, making it appear to the test as if it where synchronous.

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

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

Now the test should pass, given there is no unexpected delay in the Observable subscription, in which case we can even pass a millisecond delay in the tick call tick(1000).

This (fakeAsync) is a useful feature, but the problem is that when we use templateUrl in our @Components, it makes an XHR call, and XHR calls can't be made in a fakeAsync. There are situations where you can mock the service to make it synchronous, as mentioned in this post, but in some cases it's just not feasible or just too difficult. In the case of forms, it's just not feasible.

For this reason, when working with forms, I tend to put the templates in template instead of an outside templateUrl and break the form into smaller components if they are really big (just to not have a huge string in the component file). The only other option I can think of is to use a setTimeout inside the test, to let the asynchronous operation pass. It's a matter of preference. I just decided to go with the inline templates when workings with forms. It breaks the consistency of my app structure, but I don't like the setTimeout solution.

Now as far as the actual testing for forms, the best source I found was just to look at the source code integration tests. You'll want to change the tag to the version of Angular you are using, as the default master branch may be different from the version you are using.

Below are a few examples.

When testing inputs what you want to is change the input value on the nativeElement, and the dispatch an input event using dispatchEvent. For example

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

This is a simple test taken from the source integration test. Below there are more test examples, one more taken from the source, and a couple that are not, just to show other ways that are not in the tests.

For your particular case, it looks like you are using the (ngModelChange), where you are assigning it the call to onPaymentTermChange(). If this is the case, your implementation doesn't make much sense. (ngModelChange) is already going to spit out something when the value changes, but you are subscribing each time the model changes. What you should be doing is accepting the $event parameter what is emitted by the change event

(ngModelChange)="onPaymentTermChange($event)"

You will get passed the new value every time it changes. So just use that value in your method, instead of subscribing. The $event will be the new value.

If you do want to use the valueChange on the FormControl, you should instead start listening to it in ngOnInit, so you are only subscribing once. You'll see an example below. Personally I wouldn't go this route. I would just go with the way you are doing, but instead of subscribing on the change, just accept the event value from the change (as previously described).

Here are some complete tests

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);
  }
}
查看更多
登录 后发表回答