Testing component logic with Angular2 TestComponentBuilder

By : lexith
Source: Stackoverflow.com
Question!

There are a lot of different approaches to unit test your angular application you can find at the moment. A lot are already outdated and basically there's no real documentation at this point. So im really not sure which approach to use.

It seems a good approach at the moment is to use TestComponentBuilder, but i have some trouble to test parts of my code especially if a function on my component uses an injected service which returns an observable.

For example a basic Login Component with a Authentication Service (which uses a BackendService for the requests). I leave out the templates here, because i don't want to test them with UnitTests (as far as i understood, TestComponentBuilder is pretty useful for this, but i just want to use a common approach for all my unit tests, and the it seems that TestComponentBuilder is supposed to handle every testable aspect, please correct me if i'm wrong here)

So i got my LoginComponent:

export class LoginComponent {
    user:User;
    isLoggingIn:boolean;
    errorMessage:string;

    username:string;
    password:string;

    constructor(private _authService:AuthService, private _router:Router) {
        this._authService.isLoggedIn().subscribe(isLoggedIn => {
            if(isLoggedIn) {
                this._router.navigateByUrl('/anotherView');
            }
        });
    }

    login():any {
        this.errorMessage = null;
        this.isLoggingIn = true;
        this._authService.login(this.username, this.password)
            .subscribe(
                user => {
                    this.user = user;
                    setTimeout(() => {
                        this._router.navigateByUrl('/anotherView');
                    }, 2000);
                },
                errorMessage => {
                    this.password = '';
                    this.errorMessage = errorMessage;
                    this.isLoggingIn = false;
                }
            );
    }
}

My AuthService:

@Injectable()
export class AuthService {

    private _user:User;
    private _urls:any = {
        ...
    };

    constructor( private _backendService:BackendService,
                 @Inject(APP_CONFIG) private _config:Config,
                 private _localStorage:LocalstorageService,
                 private _router:Router) {
        this._user = _localStorage.get(LOCALSTORAGE_KEYS.CURRENT_USER);
    }

    get user():User {
        return this._user || this._localStorage.get(LOCALSTORAGE_KEYS.CURRENT_USER);
    }

    set user(user:User) {
        this._user = user;
        if (user) {
            this._localStorage.set(LOCALSTORAGE_KEYS.CURRENT_USER, user);
        } else {
            this._localStorage.remove(LOCALSTORAGE_KEYS.CURRENT_USER);
        }
    }

    isLoggedIn (): Observable<boolean> {
        return this._backendService.get(this._config.apiUrl + this._urls.isLoggedIn)
            .map(response => {
                return !(!response || !response.IsUserAuthenticated);
            });
    }

    login (username:string, password:string): Observable<User> {
        let body = JSON.stringify({username, password});

        return this._backendService.post(this._config.apiUrl + this._urls.login, body)
            .map(() => {
                this.user = new User(username);
                return this.user;
            });
    }

    logout ():Observable<any> {
        return this._backendService.get(this._config.apiUrl + this._urls.logout)
            .map(() => {
                this.user = null;
                this._router.navigateByUrl('/login');
                return true;
            });
    }
}

and finally my BackendService:

@Injectable()
export class BackendService {
    _lastErrorCode:number;

    private _errorCodes = {
        ...
    };

    constructor( private _http:Http, private _router:Router) {
    }

    post(url:string, body:any):Observable<any> {
        let options = new RequestOptions();

        this._lastErrorCode = 0;

        return this._http.post(url, body, options)
            .map((response:any) => {

                ...

                return body.Data;
            })
            .catch(this._handleError);
    }

    ...  

    private _handleError(error:any) {

        ...

        let errMsg = error.message || 'Server error';
        return Observable.throw(errMsg);
    }
}

Now i want to test the basic logic of logging in, one time it should fail and i expect an error message (which is thrown by my BackendService in its handleError function) and in another test it should login and set my User-object

This is my current approach for my Login.component.spec:

Updated: added fakeAsync like suggested in G�nters answer.

export function main() {
    describe('Login', () => {

        beforeEachProviders(() => [
            ROUTER_FAKE_PROVIDERS
        ]);

        it('should try and fail logging in',
            inject([TestComponentBuilder], fakeAsync((tcb: TestComponentBuilder) => {
                tcb.createAsync(TestComponent)
                    .then((fixture: any) => {
                        tick();
                        fixture.detectChanges();
                        let loginInstance = fixture.debugElement.children[0].componentInstance;

                        expect(loginInstance.errorMessage).toBeUndefined();

                        loginInstance.login();
                        tick();
                        fixture.detectChanges();
                        expect(loginInstance.isLoggingIn).toBe(true);

                        fixture.detectChanges();
                        expect(loginInstance.isLoggingIn).toBe(false);
                        expect(loginInstance.errorMessage.length).toBeGreaterThan(0);
                    });
            })));

        it('should log in',
            inject([TestComponentBuilder], fakeAsync((tcb: TestComponentBuilder) => {
                tcb.createAsync(TestComponent)
                    .then((fixture: any) => {
                        tick();
                        fixture.detectChanges();
                        let loginInstance = fixture.debugElement.children[0].componentInstance;

                        loginInstance.username = 'abc';
                        loginInstance.password = '123';

                        loginInstance.login();

                        tick();
                        fixture.detectChanges();

                        expect(loginInstance.isLoggingIn).toBe(true);
                        expect(loginInstance.user).toEqual(jasmine.any(User));
                    });
            })));

    });

}

@Component({
    selector: 'test-cmp',
    template: `<my-login></my-login>`,
    directives: [LoginComponent],
    providers: [
        HTTP_PROVIDERS,
        provide(APP_CONFIG, {useValue: CONFIG}),
        LocalstorageService,
        BackendService,
        AuthService,
        BaseRequestOptions,
        MockBackend,
        provide(Http, {
            useFactory: function(backend:ConnectionBackend, defaultOptions:BaseRequestOptions) {
                return new Http(backend, defaultOptions);
            },
            deps: [MockBackend, BaseRequestOptions]
        })
    ]
})
class TestComponent {
}

There are several issues with this test.

  • ERROR: 'Unhandled Promise rejection:', 'Cannot read property 'length' of null' I get this for the test of `loginInstance.errorMessage.length
  • Expected true to be false. in the first test after i called login
  • Expected undefined to equal <jasmine.any(User)>. in the second test after it should have logged in.

Any hints how to solve this? Am i using a wrong approach here? Any help would be really appreciated (and im sorry for the wall of text / code ;) )

By : lexith


Answers
As you can't know when this._authService.login(this.username, this.password).subscribe( ... ) is actually called you can't just continue the test synchronically and assume the subscribe callback has happened. In fact it can't yet have happened because sync code (your test) is executed to the end first.

  • You can add artificial delays (ugly and flaky)
  • You can provide observables or promises in your component that emit/resolve when something you want to test is actually done (ugly because test code added to production code)
  • I guess the best option is using fakeAsync which provides more control about async execution during tests (I haven't used it myself)
  • As far as I know there will come support in Angular tests using zone, to wait for the async queue to become empty before the test continues (I don't know details about this neither).


This video can help you solving your question :)
By: admin