Angular 2: Testing [(ngModel)] two way data binding - events

I am trying to test the two way data binding of ngModel with the following code, but when I am running my test I always get: Expected '' to be 'test#wikitude.com', 'searchQuery property changes after text input'. Maybe it has something to do with the searchField.dispatchEvent part, but so far I couldn't figure out why the test is not changing the textContent of my displayField. The project was built with angular-cli": "1.0.0-beta.15. I tried to follow this guide but so far had no luck. Would be nice if you could help me make my test pass. I am not sure if I have to use fixture.whenStable() - as I've seen it used in the answer to another question - but I don't think that typing text into an input field is an asynchronous activity - I also implemented the sendInput() method mentioned in this question, but so far without any success.
This is my component:
import { Component, OnInit } from '#angular/core';
#Component({
selector: 'app-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.css']
})
export class SearchComponent implements OnInit {
searchQuery: string;
active: boolean = false;
onSubmit(): void {
this.active = true;
}
}
This is my component template:
<input id="name" [(ngModel)]="searchQuery" placeholder="customer">
<h2><span>{{searchQuery}}</span></h2>
And here are is my spec:
/* tslint:disable:no-unused-variable */
import {TestBed, async, ComponentFixture, tick} from '#angular/core/testing';
import { SearchComponent } from './search.component';
import {CommonModule} from "#angular/common";
import {FormsModule} from "#angular/forms";
import {By} from "#angular/platform-browser";
describe('Component: SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [SearchComponent],
imports: [
CommonModule,
FormsModule
]
});
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
});
it('should bind the search input to the searchQuery variable', () => {
const searchInputText: string = 'test#wikitude.com';
const searchField: HTMLInputElement = fixture.debugElement.query(By.css('input')).nativeElement;
const displayField: HTMLElement = fixture.debugElement.query(By.css('span')).nativeElement;
searchField.value = searchInputText;
searchField.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(displayField.textContent).toBe(searchInputText, 'searchQuery property changes after text input');
});
});
Update:
I changed my test to the following, which made it pass - as long as the input field is not inside a form tag:
/* tslint:disable:no-unused-variable */
import {TestBed, async, ComponentFixture, tick} from '#angular/core/testing';
import { SearchComponent } from './search.component';
import {CommonModule} from "#angular/common";
import {FormsModule} from "#angular/forms";
import {By} from "#angular/platform-browser";
describe('Component: SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
function sendInput(text: string, inputElement: HTMLInputElement) {
inputElement.value = text;
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
return fixture.whenStable();
}
beforeEach(done => {
declarations: [SearchComponent],
imports: [
CommonModule,
FormsModule
]
});
TestBed.compileComponents().then(() => {
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
done();
})
});
it('should bind the search input to the searchQuery variable', done => {
const searchInputText: string = 'test#wikitude.com';
const searchField: HTMLInputElement = fixture.debugElement.query(By.css('#name')).nativeElement;
sendInput(searchInputText, searchField).then(() => {
expect(component.searchQuery).toBe(searchInputText);
done();
});
});
});
Here is the updated template:
<form>
<input id="name" [(ngModel)]="searchQuery" placeholder="customer" name="name" #name="ngModel">
</form>
<h2><span>{{searchQuery}}</span></h2>
The test result I get is: Expected undefined to be 'test#wikitude.com'.

Related

How to set state or dispatch actions from a navigator (ex : cypress testing)

A good practice given by Cypress (e2e testing) is to set the state of the app programmatically rather than using the UI. This of course makes sense.
On this video https://www.youtube.com/watch?v=5XQOK0v_YRE Brian Mann propose this solution to expose a Redux store :
Is there any possibility with NGXS to have access to the different state programmatically during testing ? An example is for the login process : dispatching directly a Login action or setting the store with the access token, to be logged in before any test, would be nice.
This cofnfiguration works for me:
in app folder in model:
export interface IWindowCypress {
Cypress: {
__store__: Store;
};
}
in app.module.ts:
import {BrowserModule} from '#angular/platform-browser';
import {NgModule} from '#angular/core';
import {NgxsModule, Store} from '#ngxs/store';
import {AppComponent, IWindowCypress} from './app.component';
import {ZooState} from './state/zoo.state';
import {NgxsReduxDevtoolsPluginModule} from '#ngxs/devtools-plugin';
#NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule, NgxsModule.forRoot([ZooState], {}),
NgxsReduxDevtoolsPluginModule.forRoot()
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
constructor(protected store: Store) {
const windowSore: IWindowCypress = window as unknown as IWindowCypress;
if (windowSore.Cypress) {
console.log('ustawiƂem store');
windowSore.Cypress.__store__ = store;
}
}
}
using in app component:
import {Component} from '#angular/core';
import {Store} from '#ngxs/store';
import {FeedAnimals} from './state/zoo.state';
/// <reference types="Cypress" />
export interface IWindowCypress {
Cypress: {
__store__: Store;
};
}
#Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
title = 'cypress-ngxs';
constructor() {
const windowSore: IWindowCypress = window as unknown as IWindowCypress;
if (windowSore.Cypress) {
(windowSore.Cypress.__store__ as Store).dispatch(new FeedAnimals());
}
}
}
using in cypress spec:
/// <reference types="Cypress" />
import {Store} from '#ngxs/store';
import {IWindowCypress} from 'src/app/app.component';
import {FeedAnimals, ZooState} from '../../../src/app/state/zoo.state';
import {Observable} from 'rxjs';
describe('My Second Test Suite', () => {
it('My FirstTest case', () => {
cy.visit(' http://localhost:4200/ ');
cy.get('.content > :nth-child(2)').should(item => {
const windowSore: IWindowCypress = window as unknown as IWindowCypress;
if (windowSore.Cypress) {
// get store
const store: Store = windowSore.Cypress.__store__;
// declare observable
const myObs: Observable<boolean> = store.select(ZooState.zoo$);
// subscribe
myObs.pipe().subscribe((feed) => console.log('from subscribe: ', feed));
// make some dispatch
(windowSore.Cypress.__store__ as Store).dispatch(new FeedAnimals());
(windowSore.Cypress.__store__ as Store).dispatch(new FeedAnimals());
(windowSore.Cypress.__store__ as Store).dispatch(new FeedAnimals());
(windowSore.Cypress.__store__ as Store).dispatch(new FeedAnimals());
}
});
});
});
and zoo state:
import {Injectable} from '#angular/core';
import {Action, Selector, State, StateContext} from '#ngxs/store';
export class FeedAnimals {
static readonly type = '[Zoo] FeedAnimals';
}
export interface ZooStateModel {
feed: boolean;
}
#State<ZooStateModel>({
name: 'zoo',
defaults: {
feed: false
}
})
#Injectable()
export class ZooState {
#Selector()
static zoo$(state: ZooStateModel): boolean {
return state.feed;
}
#Action(FeedAnimals)
feedAnimals(ctx: StateContext<ZooStateModel>): void {
console.log('fedeeeeeed');
const state = ctx.getState();
ctx.setState({
...state,
feed: !state.feed
});
}
}

How to mock ActivatedRoute in a jasmine test

I'm trying to mock the ActivatedRoute object is a test to simulate the passing of a parameter.
However every time the jasmine tests runs I get the following error:
Error: Can't resolve all parameters for ActivatedRoute: (?, ?, ?, ?, ?, ?, ?, ?).
at syntaxError (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:485:22)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver._getDependenciesMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15700:1)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver._getTypeMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15535:1)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver._getInjectableMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15515:1)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver.getProviderMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15875:1)
at http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15786:1
at Array.forEach (<anonymous>)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver._getProvidersMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15746:1)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver.getNonNormalizedDirectiveMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15007:1)
at CompileMetadataResolver.webpackJsonp../node_modules/#angular/compiler/esm5/compiler.js.CompileMetadataResolver._getEntryComponentMetadata (http://localhost:9876/_karma_webpack_/webpack:/node_modules/#angular/compiler/esm5/compiler.js:15848:26)
The Test looks like as follows:
import { TestBed } from '#angular/core/testing';
import { ReviewComponent } from './review.component';
import { ActivatedRoute, Router, Params } from '#angular/router';
import {AppModule} from "../app.module";
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
class MockRouter {
navigate = jasmine.createSpy('navigate');
}
class MockActivatedRoute extends ActivatedRoute {
params: Observable<Params>;
constructor(parameters?: { [key: string]: any; }) {
super();
this.params = Observable.of(parameters);
}
}
describe('ReviewComponent', () => {
let mockActivatedRoute: MockActivatedRoute;
let mockRouter: MockRouter;
beforeEach(() => {
mockActivatedRoute = new MockActivatedRoute({'id': 1});
mockRouter = new MockRouter();
TestBed.configureTestingModule({
declarations: [ReviewComponent],
providers: [
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: Router, useValue: mockRouter}
],
imports: [AppModule]
}).compileComponents();
});
// beforeEach(async(() => {
//
// TestBed.configureTestingModule({
// declarations: [ ReviewComponent ],
// providers: [{provide: ActivatedRoute, useValue: activeRoute},
// {provide: Router, useValue: mockRouter}]
// })
// .compileComponents();
// }));
it('should create', () => {
const fixture = TestBed.createComponent(ReviewComponent);
fixture.detectChanges();
const editComponent = fixture.debugElement.componentInstance;
expect(editComponent).toBeTruthy();
});
// it('should have a review rating', () => {
// const compiled = fixture.debugElement;
// const title = compiled.query(By.css('.reviewRating'));
// expect(title).not.toBeNull();
// });
//
// it('should have a review product name', () => {
// const compiled = fixture.debugElement;
// const productName = compiled.query(By.css('.review-title'));
// expect(productName).not.toBeNull()
// });
//
// it('should have a header image', () => {
// const compiled = fixture.debugElement;
// const title = compiled.query(By.css('.reviewImageMain'));
// expect(title).not.toBeNull();
// });
});
I've tried a number of things to mock the object however as you can see from the log, for some reason the TestBed is not using the mock and is instead using import.
Can anyone help? I've lost so many hours today to this problem.
I almost forgot, the component looks like this:
import { Component, OnInit } from '#angular/core';
import {ActivatedRoute, Router} from "#angular/router";
import {OnDestroy} from "#angular/core/src/metadata/lifecycle_hooks";
#Component({
selector: 'app-review',
templateUrl: './review.component.html',
providers: [ActivatedRoute],
styleUrls: ['./review.component.css']
})
export class ReviewComponent implements OnInit, OnDestroy {
id: string;
private sub: any;
constructor(private route: ActivatedRoute, private router: Router){
}
cancel() {
this.router.navigate(['/search']);
}
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
this.id = params['id'];
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
}
Got it :)
Wow, that was inconspicuous.
in review ReviewComponent in the providers I had included
providers: [ActivatedRoute]
This caused the the exception to happen, once I removed this line the test began to pass.
6 hours lost but bug found :)
import { Component, OnInit } from '#angular/core';
import {ActivatedRoute, Router} from "#angular/router";
import {OnDestroy} from "#angular/core/src/metadata/lifecycle_hooks";
#Component({
selector: 'app-review',
templateUrl: './review.component.html',
providers: [],
styleUrls: ['./review.component.css']
})
export class ReviewComponent implements OnInit, OnDestroy {
id: string;
private sub: any;
constructor(private route: ActivatedRoute, private router: Router){
}
cancel() {
this.router.navigate(['/search']);
}
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
this.id = params['id'];
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
}

NgxLocalStorage Doesn't Retrieve Value

I'm learning how to use the Visual Studio 2017 SPA Template for Angular 2.
For this exercise I would just like my HomeComponent to display the name of the logged on user stored in local storage (NgxLocalStorage) after logging in on my AppLoginComponent. https://www.npmjs.com/package/ngx-localstorage
I've researched this issue and I believe I'm going down the right track but for some reason my HomeComponent doesn't see the key/value pair in localStorage. However, I can see it in Chrome's Developer Tools after I set it in login().
NgxLocalStorage has a method called get, not getItem but it appears to work the same way as getItem. Unfortunately it's not retrieving my value.
I'm pretty new to Angular 2, I'm sure I'm just missing something somewhere, please help.
I have imported NgxLocalStorageModule into NgModule in app.module.shared:
import { NgModule } from '#angular/core';
import { CommonModule } from '#angular/common';
import { FormsModule } from '#angular/forms';
import { HttpModule } from '#angular/http';
import { RouterModule } from '#angular/router';
import { NgxLocalStorageModule } from 'ngx-localstorage';
import { AppComponent } from './components/app/app.component';
import { NavMenuComponent } from './components/navmenu/navmenu.component';
import { HomeComponent } from './components/home/home.component';
import { AppLoginComponent } from './components/applogin/applogin.component';
import { FacebookService, FacebookModule } from 'ngx-facebook/dist/esm/index';
#NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
AppLoginComponent
],
imports: [
CommonModule,
HttpModule,
FormsModule,
RouterModule.forRoot([
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent },
{ path: 'applogin', component: AppLoginComponent },
{ path: '**', redirectTo: 'home' }
]),
FacebookModule.forRoot(),
NgxLocalStorageModule.forRoot()
],
providers: [FacebookService, NgxLocalStorageModule]
})
export class AppModuleShared {
}
In my HomeComponent I have:
import { Component } from '#angular/core';
import { LocalStorageService } from 'ngx-localstorage';
#Component({
selector: 'home',
templateUrl: './home.component.html'
})
export class HomeComponent {
currentUser: string;
constructor(private localStorage: LocalStorageService) {
this.currentUser = JSON.parse(localStorage.get('currentUser') || '');
}
}
In AppLoginComponent I have:
import { Component, NgZone } from '#angular/core';
import { FacebookService, InitParams, LoginResponse } from 'ngx-facebook/dist/esm/index';
import { LocalStorageService } from 'ngx-localstorage';
#Component({
selector: 'applogin',
templateUrl: './applogin.component.html'
})
export class AppLoginComponent {
public loggedIn = false;
name = "";
constructor(private _ngZone: NgZone, private fb: FacebookService, localStorage: LocalStorageService) {
let initParams: InitParams = {
appId: '123456789',
xfbml: true,
version: 'v2.8'
};
fb.init(initParams);
}
login() {
var self = this;
this.fb.login()
.then((res: LoginResponse) => {
if (res.authResponse) {
this.fb.api('/me')
.then((res: any) => {
self._ngZone.run(() => {
self.name = res.name;
self.loggedIn = true;
localStorage.setItem('currentUser', res.name);
});
});
} else {
alert('Not authorized.');
}
})
.catch();
}
I have created plunker where everything works. You press login button it will navigate to different component and show user in console the only difference from your code i used
this.localStorage.set('item', item);
and this.localStorage.get('item');
also in your code
this.fb.api('/me')
.then((res: any) => {
self._ngZone.run(() => {
self.name = res.name;
self.loggedIn = true;
localStorage.setItem('currentUser', res.name);
});
});
you can't use like this services outside constructor and don't use self you need to add 'this'. and in you constructor prefix localStorage with private
and do initialization better in OnInit hook.
import { Component, NgZone, OnInit } from '#angular/core';
import { FacebookService, InitParams, LoginResponse } from 'ngx-facebook/dist/esm/index';
import { LocalStorageService } from 'ngx-localstorage';
#Component({
selector: 'applogin',
templateUrl: './applogin.component.html'
})
export class AppLoginComponent implements OnInit {
public loggedIn = false;
name = "";
constructor(private _ngZone: NgZone, private fb: FacebookService, private localStorage: LocalStorageService) {
}
ngOnInit() {
let initParams: InitParams = {
appId: '123456789',
xfbml: true,
version: 'v2.8'
};
fb.init(initParams);
}
login() {
this.fb.login()
.then((res: LoginResponse) => {
if (res.authResponse) {
this.fb.api('/me')
.then((res: any) => {
this._ngZone.run(() => {
this.name = res.name;
this.loggedIn = true;
this.localStorage.set('currentUser', res.name);
});
});
} else {
alert('Not authorized.');
}
})
.catch();
}
and in app.module.shared.ts remove this line
providers: [FacebookService, NgxLocalStorageModule]
cause forRoot is importing them already. should be like this
#NgModule({
declarations: [
AppComponent,
NavMenuComponent,
HomeComponent,
AppLoginComponent
],
imports: [
CommonModule,
HttpModule,
FormsModule,
RouterModule.forRoot([
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent },
{ path: 'applogin', component: AppLoginComponent },
{ path: '**', redirectTo: 'home' }
]),
FacebookModule.forRoot(),
NgxLocalStorageModule.forRoot()
]
})
export class AppModuleShared {
}
and the last
import { FacebookService, FacebookModule } from 'ngx-facebook/dist/esm/index';
try to use import without dist
import { FacebookModule } from 'ngx-facebook';
The input has to be a string. You can put in some mock data like
localStorage.setItem('currentUser', 'TrevorBrooks');
and retrieve it via get to be sure there is a item saved. And check what data type you are sending. Is it a user object or is it just the name?
Greetings
You should use NgOnInit, is the best approach for your problem than using the constructor since the constructor is for initialization, dependency injection among others.. so, what could be happening is that the data is not yet available by the time you request it. Besides in the npmjs.com page they clearly add an example using ngOnInit so i guess they saw this issue coming.
In your components, do import { .., OnInit, .. } from '#angular/core';
`
so you'd have something like:
import { Component, NgZone, OnInit } from '#angular/core';
and in your component export class:
export class AppLoginComponent implements OnInit{
ngOnInit() {
//write your code here
}
1.Make sure you have been installed the local storage module from npm
npm install --save angular2-localstorage
2.Import the WebStorageModule in your app module:
import {Component} from "angular2/core";
import {WebStorageModule, LocalStorageService} from "angular2-localstorage";
#NgModule({
import: [WebStorageModule]
#Component({
providers: [LocalStorageService]
})
export class AppModule {}
2.Use the LocalStorage decorator
import {LocalStorage, SessionStorage} from "angular2-localstorage/WebStorage";
class MySuperComponent {
#LocalStorage() public lastSearchQuery:Object = {};
#LocalStorage('differentLocalStorageKey') public lastSearchQuery:Object = {};
}
Example
#Component({
selector: 'app-login',
template: `
<form>
<div>
<input type="text" [(ngModel)]="username" placeholder="Username" />
<input type="password" [(ngModel)]="password" placeholder="Password" />
</div>
<input type="checkbox" [(ngModel)]="rememberMe" /> Keep me logged in
<button type="submit">Login</button>
</form>
`
})
class AppLoginComponent {
//here happens the magic. `username` is always restored from the localstorage when you reload the site
#LocalStorage() public username:string = '';
public password:string;
//here happens the magic. `rememberMe` is always restored from the localstorage when you reload the site
#LocalStorage() public rememberMe:boolean = false;
}
View
#Component({
selector: 'admin-menu',
template: `
<div *ngFor="#menuItem of menuItems() | mapToIterable; #i = index">
<h2 (click)="hiddenMenuItems[i] = !!!hiddenMenuItems[i]">
{{i}}: {{category.label}}
</h2>
<div style="padding-left: 15px;" [hidden]="hiddenMenuItems[i]">
<a href>Some sub menu item 1</a>
<a href>Some sub menu item 2</a>
<a href>Some sub menu item 3</a>
</div>
</div>
`
})
class AdminMenuComponent {
public menuItems = [{title: 'Menu1'}, {title: 'Menu2'}, {title: 'Menu3'}];
//here happens the magic. `hiddenMenuItems` is always restored from the localstorage when you reload the site
#LocalStorage() public hiddenMenuItems:Array<boolean> = [];
//here happens the magic. `profile` is always restored from the sessionStorage when you reload the site from the current tab/browser. This is perfect for more sensitive information that shouldn't stay once the user closes the browser.
#SessionStorage() public profile:any = {};
}
For more Clarification refer this link link

Validation Error Message not getting displayed for custom validation in Angular 2

I have a register form where user need to provide username. When customer enters username, I want to show validation error message if that username already exists in db or not.
register.html
<-- code here-->
<div class="form-group">
<label for="username" class="col-sm-3 control-label">UserName</label>
<div class=" col-sm-6">
<input type="text" ngControl="userName" maxlength="45" class="form-control" [(ngModel)]="parent.userName" placeholder="UserName" #userName="ngForm" required data-is-unique/>
<validation-message control="userName"></validation-message>
</div>
</div>
<--code here-->
register.component.ts
import {Component} from 'angular2/core';
import {NgForm, FormBuilder, Validators, FORM_DIRECTIVES} from 'angular2/common';
import {ValidationService} from '../services/validation.service';
import {ValidationMessages} from './validation-messages.component';
#Component({
selector: 'register',
templateUrl: './views/register.html',
directives: [ROUTER_DIRECTIVES, ValidationMessages, FORM_DIRECTIVES],
providers: []
})
export class ParentSignUpComponent {
parentSignUpForm: any;
constructor(private _formBuilder: FormBuilder) {
this._stateService.isAuthenticatedEvent.subscribe(value => {
this.onAuthenticationEvent(value);
});
this.parent = new ParentSignUpModel();
this.parentSignUpForm = this._formBuilder.group({
'firstName': ['', Validators.compose([Validators.required, Validators.maxLength(45), ValidationService.nameValidator])],
'middleName': ['', Validators.compose([Validators.maxLength(45), ValidationService.nameValidator])],
'lastName': ['', Validators.compose([Validators.required, Validators.maxLength(45), ValidationService.nameValidator])],
'userName': ['', Validators.compose([Validators.required, ValidationService.checkUserName])]
});
}
}
validation-message.component
import {Component, Host} from 'angular2/core';
import {NgFormModel} from 'angular2/common';
import {ValidationService} from '../services/validation.service';
#Component({
selector: 'validation-message',
inputs: ['validationName: control'],
template: `<div *ngIf="errorMessage !== null" class="error-message"> {{errorMessage}}</div>`
})
export class ValidationMessages {
private validationName: string;
constructor (#Host() private _formDir: NgFormModel) {}
get errorMessage() {
let control = this._formDir.form.find(this.validationName);
for (let propertyName in control.errors) {
if (control.errors.hasOwnProperty(propertyName) && control.touched) {
return ValidationService.getValidatorErrorMessage(propertyName);
}
}
return null;
}
}
validation-service.ts
import {Injectable, Injector} from 'angular2/core';
import {Control} from 'angular2/common';
import {Observable} from 'rxjs/Observable';
import {Http, Response, HTTP_PROVIDERS} from 'angular2/http';
import 'rxjs/Rx';
interface ValidationResult {
[key:string]:boolean;
}
#Injectable()
export class ValidationService {
static getValidatorErrorMessage(code: string) {
let config = {
'required': 'This field is required!',
'maxLength': 'Field is too long!',
'invalidName': 'This field can contain only alphabets, space, dot, hyphen, and apostrophe.',
'userAlreadyInUse': 'UserName selected already in use! Please try another.'
};
return config[code];
}
static checkUserName(control: Control): Promise<ValidationResult> {
let injector = Injector.resolveAndCreate([HTTP_PROVIDERS]);
let http = injector.get(Http);
let alreadyExists: boolean;
if (control.value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
http.get('/isUserNameUnique/' + control.value).map(response => response.json()).subscribe(result => {
if (result === false) {
resolve({'userAlreadyInUse': true});
} else {
resolve(null);
}
});
}, 1000);
});
}
}
}
Now, when i run, and give a username that already exists in db, the value of 'result' variable i am getting as false, which is expected and correct. But validation error message is not getting displayed. I am able to run and get validation error message for other custom validation functions. I am using Angular 2.0.0-beta.15. Can somebody help me to understand what could be the issue?
There are some known issues with async validation
https://github.com/angular/angular/issues/1068
https://github.com/angular/angular/issues/7538
https://github.com/angular/angular/issues/8118
https://github.com/angular/angular/issues/8923
https://github.com/angular/angular/issues/8022
This code can be simplified
return new Promise((resolve, reject) => {
setTimeout(() => {
http.get('/isUserNameUnique/' + control.value).map(response => response.json())
.subscribe(result => {
if (result === false) {
resolve({'userAlreadyInUse': true});
} else {
resolve(null);
}
});
}, 1000);
});
to
return http.get('/isUserNameUnique/' + control.value).map(response => response.json())
.timeout(200, new Error('Timeout has occurred.'));
.map(result => {
if (result === false) {
resolve({'userAlreadyInUse': true});
} else {
resolve(null);
}
}).toPromise();
Don't forget to import map, timeout, and toPromise.
If you use subscribe() instead of then() on the caller site, then you can event omit toPromise()
if you look into this -
'userName': ['', Validators.compose([Validators.required, ValidationService.checkUserName])] });
-- you can see I am using both synchronous and asynchronous validations together. When i changed method for checkUserName like 'Validators.composeAsync(ValidationService.checkUserName)' instead of Validators.compose method, error message got displayed.

Testing promise in Angular 2 ngOnInit

I have an Angular 2 component I am trying to put under test, but I am having trouble because the data is set in the ngOnInit function, so is not immediately available in the unit test.
user-view.component.ts:
import {Component, OnInit} from 'angular2/core';
import {RouteParams} from 'angular2/router';
import {User} from './user';
import {UserService} from './user.service';
#Component({
selector: 'user-view',
templateUrl: './components/users/view.html'
})
export class UserViewComponent implements OnInit {
public user: User;
constructor(
private _routeParams: RouteParams,
private _userService: UserService
) {}
ngOnInit() {
const id: number = parseInt(this._routeParams.get('id'));
this._userService
.getUser(id)
.then(user => {
console.info(user);
this.user = user;
});
}
}
user.service.ts:
import {Injectable} from 'angular2/core';
// mock-users is a static JS array
import {users} from './mock-users';
import {User} from './user';
#Injectable()
export class UserService {
getUsers() : Promise<User[]> {
return Promise.resolve(users);
}
getUser(id: number) : Promise<User> {
return Promise.resolve(users[id]);
}
}
user-view.component.spec.ts:
import {
beforeEachProviders,
describe,
expect,
it,
injectAsync,
TestComponentBuilder
} from 'angular2/testing';
import {provide} from 'angular2/core';
import {RouteParams} from 'angular2/router';
import {DOM} from 'angular2/src/platform/dom/dom_adapter';
import {UserViewComponent} from './user-view.component';
import {UserService} from './user.service';
export function main() {
describe('User view component', () => {
beforeEachProviders(() => [
provide(RouteParams, { useValue: new RouteParams({ id: '0' }) }),
UserService
]);
it('should have a name', injectAsync([TestComponentBuilder], (tcb: TestComponentBuilder) => {
return tcb.createAsync(UserViewComponent)
.then((rootTC) => {
spyOn(console, 'info');
let uvDOMEl = rootTC.nativeElement;
rootTC.detectChanges();
expect(console.info).toHaveBeenCalledWith(0);
expect(DOM.querySelectorAll(uvDOMEl, 'h2').length).toBe(0);
});
}));
});
}
The route param is getting passed correctly, but the view hasn't changed before the tests are run. How do I set up a test that happens after the promise in ngOnInit is resolved?
IMO the best solution for this use case is to just make a synchronous mock service . You can't use fakeAsync for this particular case because of the XHR call for templateUrl. And personally I don't think the "hack" to make ngOnInit return a promise is very elegant. And you should not have to call ngOnInit directly, as it should be called by the framework.
You should already be using mocks anyway, as you are only unit testing the component, and don't want to be dependent on the real service working correctly.
To make a service that is synchronous, simple return the service itself from whatever methods are being called. You can then add your then and catch (subscribe if you are using Observable) methods to the mock, so it acts like a Promise. For example
class MockService {
data;
error;
getData() {
return this;
}
then(callback) {
if (!this.error) {
callback(this.data);
}
return this;
}
catch(callback) {
if (this.error) {
callback(this.error);
}
}
setData(data) {
this.data = data;
}
setError(error) {
this.error = error;
}
}
This has a few benefits. For one it gives you a lot of control over the service during execution, so you can easily customize it's behavior. And of course it's all synchronous.
Here's another example.
A common thing you will see with components is the use of ActivatedRoute and subscribing to its params. This is asynchronous, and done inside the ngOnInit. What I tend to do with this is create a mock for both the ActivatedRoute and the params property. The params property will be a mock object and have some functionality that appears to the outside world like an observable.
export class MockParams {
subscription: Subscription;
error;
constructor(private _parameters?: {[key: string]: any}) {
this.subscription = new Subscription();
spyOn(this.subscription, 'unsubscribe');
}
get params(): MockParams {
return this;
}
subscribe(next: Function, error: Function): Subscription {
if (this._parameters && !this.error) {
next(this._parameters);
}
if (this.error) {
error(this.error);
}
return this.subscription;
}
}
export class MockActivatedRoute {
constructor(public params: MockParams) {}
}
You can see we have a subscribe method that behaves like an Observable#subscribe. Another thing we do is spy on the Subscription so that we can test that it is destroyed. In most cases you will have unsubscribed inside your ngOnDestroy. To set up these mocks in your test you can just do something like
let mockParams: MockParams;
beforeEach(() => {
mockParams = new MockParams({ id: 'one' });
TestBed.configureTestingModule({
imports: [ CommonModule ],
declarations: [ TestComponent ],
providers: [
{ provide: ActivatedRoute, useValue: new MockActivatedRoute(mockParams) }
]
});
});
Now all the params are set for the route, and we have access to the mock params so we can set the error, and also check the subscription spy to make sure its been unsubscribed from.
If you look at the tests below, you will see that they are all synchronous tests. No need for async or fakeAsync, and it passes with flying colors.
Here is the complete test (using RC6)
import { Component, OnInit, OnDestroy, DebugElement } from '#angular/core';
import { CommonModule } from '#angular/common';
import { ActivatedRoute } from '#angular/router';
import { Subscription } from 'rxjs/Subscription';
import { TestBed, async } from '#angular/core/testing';
import { By } from '#angular/platform-browser';
#Component({
template: `
<span *ngIf="id">{{ id }}</span>
<span *ngIf="error">{{ error }}</span>
`
})
export class TestComponent implements OnInit, OnDestroy {
id: string;
error: string;
subscription: Subscription;
constructor(private _route: ActivatedRoute) {}
ngOnInit() {
this.subscription = this._route.params.subscribe(
(params) => {
this.id = params['id'];
},
(error) => {
this.error = error;
}
);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
export class MockParams {
subscription: Subscription;
error;
constructor(private _parameters?: {[key: string]: any}) {
this.subscription = new Subscription();
spyOn(this.subscription, 'unsubscribe');
}
get params(): MockParams {
return this;
}
subscribe(next: Function, error: Function): Subscription {
if (this._parameters && !this.error) {
next(this._parameters);
}
if (this.error) {
error(this.error);
}
return this.subscription;
}
}
export class MockActivatedRoute {
constructor(public params: MockParams) {}
}
describe('component: TestComponent', () => {
let mockParams: MockParams;
beforeEach(() => {
mockParams = new MockParams({ id: 'one' });
TestBed.configureTestingModule({
imports: [ CommonModule ],
declarations: [ TestComponent ],
providers: [
{ provide: ActivatedRoute, useValue: new MockActivatedRoute(mockParams) }
]
});
});
it('should set the id on success', () => {
let fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
let debugEl = fixture.debugElement;
let spanEls: DebugElement[] = debugEl.queryAll(By.css('span'));
expect(spanEls.length).toBe(1);
expect(spanEls[0].nativeElement.innerHTML).toBe('one');
});
it('should set the error on failure', () => {
mockParams.error = 'Something went wrong';
let fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
let debugEl = fixture.debugElement;
let spanEls: DebugElement[] = debugEl.queryAll(By.css('span'));
expect(spanEls.length).toBe(1);
expect(spanEls[0].nativeElement.innerHTML).toBe('Something went wrong');
});
it('should unsubscribe when component is destroyed', () => {
let fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
fixture.destroy();
expect(mockParams.subscription.unsubscribe).toHaveBeenCalled();
});
});
Return a Promise from #ngOnInit:
ngOnInit(): Promise<any> {
const id: number = parseInt(this._routeParams.get('id'));
return this._userService
.getUser(id)
.then(user => {
console.info(user);
this.user = user;
});
}
I ran into the same issue a few days back, and found this to be the most workable solution. As far as I can tell, it doesn't impact anywhere else in the application; since #ngOnInit has no specified return type in the source's TypeScript, I doubt anything in the source code is expecting a return value from that.
Link to OnInit: https://github.com/angular/angular/blob/2.0.0-beta.6/modules/angular2/src/core/linker/interfaces.ts#L79-L122
Edit
In your test, you'd return a new Promise:
it('should have a name', injectAsync([TestComponentBuilder], (tcb: TestComponentBuilder) => {
// Create a new Promise to allow greater control over when the test finishes
//
return new Promise((resolve, reject) => {
tcb.createAsync(UserViewComponent)
.then((rootTC) => {
// Call ngOnInit manually and put your test inside the callback
//
rootTC.debugElement.componentInstance.ngOnInit().then(() => {
spyOn(console, 'info');
let uvDOMEl = rootTC.nativeElement;
rootTC.detectChanges();
expect(console.info).toHaveBeenCalledWith(0);
expect(DOM.querySelectorAll(uvDOMEl, 'h2').length).toBe(0);
// Test is done
//
resolve();
});
});
}));
}
I had the same issue, here is how I managed to fix it. I had to use fakeAsync and tick.
fakeAsync(
inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
tcb
.overrideProviders(UsersComponent, [
{ provide: UserService, useClass: MockUserService }
])
.createAsync(UsersComponent)
.then(fixture => {
fixture.autoDetectChanges(true);
let component = <UsersComponent>fixture.componentInstance;
component.ngOnInit();
flushMicrotasks();
let element = <HTMLElement>fixture.nativeElement;
let items = element.querySelectorAll('li');
console.log(items);
});
})
)

Resources