1

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 ;) )

1 Answer 1

1

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).
Sign up to request clarification or add additional context in comments.

12 Comments

So i tried this now, using fakeAsync but it doesn't make any difference. I update my question above with my adjustments. But the result is exactly the same.
provide(XHRBackend, {useClass: MockBackend}) instead of the whole provide(Http, ...) should result in the same behavior. Did you check the Http call from AuthService.login() is actually made? I think you need to make MockBackend to actually respond to the request. See angular.io/docs/ts/latest/api/http/testing/… for an example.
True, using XHRBackend shows the same behaviour. I have that snippet from the seed im using. About the login call, im trying to debug it, but i have some problems doing so with karma at the moment. a click on debug just opens a new window and nothing happens. "Failing to debug", that's something...
I'm on it. But even the documentation about MockBackend is buggy and doesn't work from scratch (grrrr) but i somehow resolved it and start to see land. If i get it working for my login example, i will provide more information. i can't be the only one struggling with this. and it's a pretty basic usecase isn't it?
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.