I am using template-driven forms in Angular 2, and I'm trying to develop them test-first. I've scoured this site and the rest of the internet and I've tried basically everything I can find (mainly bunches of tick() statements and detectChanges() everywhere in a fakeAsync) to get the NgModel attached to my input to pick up the value so it can be passed to my onSubmit function. The value of the input element sets properly, but the NgModel never updates, which then means the onSubmit function does not get the correct value from the NgModel.
Here's the template:
<form id="createWorkout" #cwf="ngForm" (ngSubmit)="showWorkout(skillCountFld)" novalidate> <input name="skillCount" id="skillCount" class="form-control" #skillCountFld="ngModel" ngModel /> <button type="submit" id="buildWorkout">Build a Workout</button> </form>
Note: I know that the value sent the ngSubmit is going to cause the test to fail, but it means I can set a break point in the function and inspect the NgModel.
Here's the Component:
import { Component, OnInit } from '@angular/core'; import {SkillService} from "../model/skill-service"; import {NgModel} from "@angular/forms"; @Component({ selector: 'app-startworkout', templateUrl: './startworkout.component.html', styleUrls: ['./startworkout.component.css'] }) export class StartworkoutComponent implements OnInit { public skillCount:String; constructor(public skillService:SkillService) { } showWorkout(value:NgModel):void { console.log('breakpoint', value.value); } ngOnInit() { } }
Here is the spec:
/* tslint:disable:no-unused-variable */ import {async, ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; import {By, BrowserModule} from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; import { StartworkoutComponent } from './startworkout.component'; import {SkillService} from "../model/skill-service"; import {Store} from "../core/store"; import {SportService} from "../model/sport-service"; import {FormsModule} from "@angular/forms"; import {dispatchEvent} from "@angular/platform-browser/testing/browser_util"; describe('StartworkoutComponent', () => { let component: StartworkoutComponent; let fixture: ComponentFixture; let element:DebugElement; let skillService:SkillService; beforeEach(async(() => { var storeSpy:any = jasmine.createSpyObj('store', ['getValue', 'storeValue', 'removeValue']); var stubSkillService:SkillService = new SkillService(storeSpy); TestBed.configureTestingModule({ declarations: [ StartworkoutComponent ], providers: [{provide:Store , useValue:storeSpy}, SportService, SkillService], imports: [BrowserModule, FormsModule] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(StartworkoutComponent); component = fixture.componentInstance; element = fixture.debugElement; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); describe('without workout', () => { let createWorkout:DebugElement; let skillCount:HTMLInputElement; let submitButton:HTMLButtonElement; beforeEach(() => { createWorkout = element.query(By.css('#createWorkout')); skillCount = element.query(By.css('#skillCount')).nativeElement; submitButton = element.query(By.css('#buildWorkout')).nativeElement; }); it('has createWorkout form', () => { expect(createWorkout).toBeTruthy(); expect(skillCount).toBeTruthy(); }); it('submits the value', fakeAsync(() => { spyOn(component, 'showWorkout').and.callThrough(); tick(); skillCount.value = '10'; dispatchEvent(skillCount, 'input'); fixture.detectChanges(); tick(50); submitButton.click(); fixture.detectChanges(); tick(50); expect(component.showWorkout).toHaveBeenCalledWith('10'); })); }); });
I'm sure I'm missing something basic/simple, but I've spent the past day combing through everything I can find with no luck.
I think maybe people are focusing on the wrong thing. I'm pretty sure at this point that I'm missing something basic about how ngForm and ngModel work. When I add
<p>{{cwf.value | json}}</p>
into the form, it just shows {}. I believe it should show a member property representing the input. If I type into the field, the value does not change. Similar things happen if I try to bind to skillCountFld. So I think the basic form setup is incorrect somehow, and the test is never going to work until the input is correctly wired to the skillCountFld controller. I just don't see what I'm missing.
To use ngModel, should import FormsModule first. html: ... <input type="text" class="form-control" id="topicId" [(ngModel)]="comic.
NgModel. Reconciles value changes in the attached form element with changes in the data model, allowing you to respond to user input with input validation and error handling. NgForm. Creates a top-level FormGroup instance and binds it to a <form> element to track aggregated form value and validation status.
name: An alternative to setting the name attribute on the form control element. See the example for using NgModel as a standalone control. standalone: When set to true, the ngModel will not register itself with its parent form, and acts as if it's not in the form.
There are a lot of tests at the Angular site that are successfully setting this without waiting for whenStable https://github.com/angular/angular/blob/874243279d5fd2bef567a13e0cef8d0cdf68eec1/modules/%40angular/forms/test/template_integration_spec.ts#L1043
That's because all code in those tests is executed inside fakeAsync
zone while you are firing fixture.detectChanges();
within beforeEach
. So fakeAsync
zone doesn't know about async operation outside its scope. When you're calling detectChanges
first time ngModel
is initialized
NgModel.prototype.ngOnChanges = function (changes) {
this._checkForErrors();
if (!this._registered)
this._setUpControl(); //<== here
and gets right callback for input event
NgForm.prototype.addControl = function (dir) {
var _this = this;
resolvedPromise.then(function () { // notice async operation
var container = _this._findContainer(dir.path);
dir._control = (container.registerControl(dir.name, dir.control));
setUpControl(dir.control, dir); // <== here
inside setUpControl
you can see function that will be called by input
event
dir.valueAccessor.registerOnChange(function (newValue) {
dir.viewToModelUpdate(newValue);
control.markAsDirty();
control.setValue(newValue, { emitModelToViewChange: false });
});
1) So if you move fixture.detectChanges
from beforeEach
to your test then it should work:
it('submits the value', fakeAsync(() => {
spyOn(component, 'showWorkout').and.callThrough();
fixture.detectChanges();
skillCount = element.query(By.css('#skillCount')).nativeElement;
submitButton = element.query(By.css('#buildWorkout')).nativeElement;
tick();
skillCount.value = '10';
dispatchEvent(skillCount, 'input');
fixture.detectChanges();
submitButton.click();
fixture.detectChanges();
expect(component.showWorkout).toHaveBeenCalledWith('10');
}));
Plunker Example
But this solution seems very complicated since you need to rewrite your code to move fixture.detectChanges
in each of your it
statements (and there is also a problem with skillCount
, submitButton
etc)
2) As Dinistro said async
together with whenStable
should also help you:
it('submits the value', async(() => {
spyOn(component, 'showWorkout').and.callThrough();
fixture.whenStable().then(() => {
skillCount.value = '10';
dispatchEvent(skillCount, 'input');
fixture.detectChanges();
submitButton.click();
fixture.detectChanges();
expect(component.showWorkout).toHaveBeenCalledWith('10');
})
}));
Plunker Example
but wait why do we have to change our code?
3) Just add async
to your beforeEach function
beforeEach(async(() => {
fixture = TestBed.createComponent(StartworkoutComponent);
component = fixture.componentInstance;
element = fixture.debugElement;
fixture.detectChanges();
}));
Plunker Example
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With