如何在 Angular2 中对 FormControl 进行单元测试
How to unit test a FormControl in 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 是一个表单控件,它 return 是一个 Observable,然后订阅它并检查 return 值。
我似乎找不到任何关于对 FormControl 进行单元测试的文档。我最接近的是这篇关于 Mocking Http requests 的文章,这是一个类似于 returning Observables 的概念,但我认为它并不完全适用。
作为参考,我正在使用 Angular RC5,运行 使用 Karma 进行测试,框架是 Jasmine。
更新
至于这个关于异步行为的答案的第一部分,我发现您可以使用 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 是异步的,所以没有及时为 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');
}));
现在测试应该通过了,假设 Observable 订阅没有意外延迟,在这种情况下我们甚至可以在 tick 调用中传递毫秒延迟 tick(1000)
。
这个 (fakeAsync
) 是一个有用的特性,但问题是当我们在 @Component
中使用 templateUrl
时,它会进行 XHR 调用,并且 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 ,但在某些情况下,它只是不可行或太难了。如果是表格,那是行不通的。
出于这个原因,在处理表单时,我倾向于将模板放在 template
而不是外部 templateUrl
中,如果表单真的很大(只是为了组件文件中没有大字符串)。我能想到的唯一其他选择是在测试中使用 setTimeout
,让异步操作通过。这是一个偏好问题。我只是决定在处理表单时使用内联模板。它破坏了我的应用程序结构的一致性,但我不喜欢 setTimeout
解决方案。
现在就表单的实际测试而言,我找到的最佳来源只是查看 source code integration tests。您需要将标签更改为您正在使用的 Angular 版本,因为默认的 master 分支可能与您正在使用的版本不同。
下面是几个例子。
测试输入时,您想要更改 nativeElement
上的输入值,并使用 dispatchEvent
调度 input
事件。例如
@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
将是新值。
如果您 想在 FormControl
上使用 valueChange
,您应该改为在 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);
}
}
我测试的方法如下:
/**
* 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 是一个表单控件,它 return 是一个 Observable,然后订阅它并检查 return 值。
我似乎找不到任何关于对 FormControl 进行单元测试的文档。我最接近的是这篇关于 Mocking Http requests 的文章,这是一个类似于 returning Observables 的概念,但我认为它并不完全适用。
作为参考,我正在使用 Angular RC5,运行 使用 Karma 进行测试,框架是 Jasmine。
更新
至于这个关于异步行为的答案的第一部分,我发现您可以使用 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 是异步的,所以没有及时为 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');
}));
现在测试应该通过了,假设 Observable 订阅没有意外延迟,在这种情况下我们甚至可以在 tick 调用中传递毫秒延迟 tick(1000)
。
这个 (fakeAsync
) 是一个有用的特性,但问题是当我们在 @Component
中使用 templateUrl
时,它会进行 XHR 调用,并且 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
出于这个原因,在处理表单时,我倾向于将模板放在 template
而不是外部 templateUrl
中,如果表单真的很大(只是为了组件文件中没有大字符串)。我能想到的唯一其他选择是在测试中使用 setTimeout
,让异步操作通过。这是一个偏好问题。我只是决定在处理表单时使用内联模板。它破坏了我的应用程序结构的一致性,但我不喜欢 setTimeout
解决方案。
现在就表单的实际测试而言,我找到的最佳来源只是查看 source code integration tests。您需要将标签更改为您正在使用的 Angular 版本,因为默认的 master 分支可能与您正在使用的版本不同。
下面是几个例子。
测试输入时,您想要更改 nativeElement
上的输入值,并使用 dispatchEvent
调度 input
事件。例如
@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
将是新值。
如果您 想在 FormControl
上使用 valueChange
,您应该改为在 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);
}
}