13
votes

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.

Edit:

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

&lt;p>{{cwf.value | json}}&lt;/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.

2
Why do you expect toHaveBeenCalledWith(10) if you pass ngModel in showWorkout function?yurzui
@Amy Blankenship, had a very similar issue with NgModel, while in the case of reactive forms module there was no problem with the bindings. Figured out how to get around it which can probably be of any help to you.Ahmad Baktash Hayeri

2 Answers

22
votes

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

3
votes

I think whenStable should do the trick in your case:

it('submits the value',() => {
    fixture.whenStable().then(() => {
        // ngModel should be available here 
    })
});

More information here: https://angular.io/docs/ts/latest/guide/testing.html#!#when-stable

EDIT: The ValueAccessor seems not to notice the change if you directly manipulate the value of the DOM-element. But it should notice, if you write the value directly with the ValueAccessor:

const skillCount= fixture.debugElement.query(By.directive(NgModel));
const ngModel= skillCount.injector.get(NgModel);

ngModel.valueAccessor.writeValue('10')