Angular 2 Unit Observable Error Testing (HTTP)

I am trying to write unit tests for my API service, but I have problems with HTTP errors. I follow this guide along with Angular2 docs, as the guide is (slightly) outdated in some minor areas.

All unit tests pass separately from those where the error is caused by the service (due to the HTTP error code code). I can say this by logging out of response.ok . From what I read, this is due to the fact that unit tests are not performed asynchronously, therefore, without waiting for an error response. However, I have no idea why this is the case here, since I used the async() utility function in the beforeEach method.

API Service

 get(endpoint: string, authenticated: boolean = false): Observable<any> { endpoint = this.formatEndpoint(endpoint); return this.getHttp(authenticated) // Returns @angular/http or a wrapper for handling auth headers .get(endpoint) .map(res => this.extractData(res)) .catch(err => this.handleError(err)); // Not in guide but should work as per docs } private extractData(res: Response): any { let body: any = res.json(); return body || { }; } private handleError(error: Response | any): Observable<any> { // TODO: Use a remote logging infrastructure // TODO: User error notifications let errMsg: string; if (error instanceof Response) { const body: any = error.json() || ''; const err: string = body.error || JSON.stringify(body); errMsg = `${error.status} - ${error.statusText || ''}${err}`; } else { errMsg = error.message ? error.message : error.toString(); } console.error(errMsg); return Observable.throw(errMsg); } 

Unit test error

 // Imports describe('Service: APIService', () => { let backend: MockBackend; let service: APIService; beforeEach(async(() => { TestBed.configureTestingModule({ providers: [ BaseRequestOptions, MockBackend, APIService, { deps: [ MockBackend, BaseRequestOptions ], provide: Http, useFactory: (backend: XHRBackend, defaultOptions: BaseRequestOptions) => { return new Http(backend, defaultOptions); } }, {provide: AuthHttp, useFactory: (http: Http, options: BaseRequestOptions) => { return new AuthHttp(new AuthConfig({}), http, options); }, deps: [Http, BaseRequestOptions] } ] }); const testbed: any = getTestBed(); backend = testbed.get(MockBackend); service = testbed.get(APIService); })); /** * Utility function to setup the mock connection with the required options * @param backend * @param options */ function setupConnections(backend: MockBackend, options: any): any { backend.connections.subscribe((connection: MockConnection) => { const responseOptions: any = new ResponseOptions(options); const response: any = new Response(responseOptions); console.log(response.ok); // Will return false during the error unit test and true in others (if spyOn log is commented). connection.mockRespond(response); }); } it('should log an error to the console on error', () => { setupConnections(backend, { body: { error: `Some strange error` }, status: 400 }); spyOn(console, 'error'); spyOn(console, 'log'); service.get('/bad').subscribe(null, e => { // None of this code block is executed. expect(console.error).toHaveBeenCalledWith("400 - Some strange error"); console.log("Make sure an error has been thrown"); }); expect(console.log).toHaveBeenCalledWith("Make sure an error has been thrown."); // Fails }); 

Update 1

when I check the first callback, response.ok is undefined. This makes me think that something is wrong with the setupConnections utility.

  it('should log an error to the console on error', async(() => { setupConnections(backend, { body: { error: `Some strange error` }, status: 400 }); spyOn(console, 'error'); //spyOn(console, 'log'); service.get('/bad').subscribe(res => { console.log(res); // Object{error: 'Some strange error'} console.log(res.ok); // undefined }, e => { expect(console.error).toHaveBeenCalledWith("400 - Some strange error"); console.log("Make sure an error has been thrown"); }); expect(console.log).toHaveBeenCalledWith("Make sure an error has been thrown."); })); 

Update 2

If, instead of catching errors in the get method, I do it explicitly on the map, then you still have the same problem.

 get(endpoint: string, authenticated: boolean = false): Observable<any> { endpoint = this.formatEndpoint(endpoint); return this.getHttp(authenticated).get(endpoint) .map(res => { if (res.ok) return this.extractData(res); return this.handleError(res); }) .catch(this.handleError); } 

Update 3

After some discussion, this question is presented

+6
source share
2 answers

From what I read, this is due to the fact that unit tests are not performed asynchronously, therefore, without waiting for an error response. However, I have no idea why this is the case here, since I used the async() utility function in the beforeEach method

You need to use it in a test case ( it ). What async does creates a test zone that waits for all async tasks to complete before the test (or test area, such as beforeEach ) completes.

Thus, async in beforeEach only expects async tasks in the method to complete before exiting. But it also needs it.

 it('should log an error to the console on error', async(() => { })) 

UPDATE

Besides the missing async , there seems to be a bug with MockConnection . If you look at mockRespond , it always calls next , mockRespond status code

 mockRespond(res: Response) { if (this.readyState === ReadyState.Done || this.readyState === ReadyState.Cancelled) { throw new Error('Connection has already been resolved'); } this.readyState = ReadyState.Done; this.response.next(res); this.response.complete(); } 

They have a mockError(Error) method that throws an error

 mockError(err?: Error) { // Matches ResourceLoader semantics this.readyState = ReadyState.Done; this.response.error(err); } 

but this does not allow you to pass Response . This is incompatible with the way the real XHRConnection , which checks the status and sends a Response either via next or error , but the same Response

 response.ok = isSuccess(status); if (response.ok) { responseObserver.next(response); // TODO(gdi2290): defer complete if array buffer until done responseObserver.complete(); return; } responseObserver.error(response); 

Sounds like a mistake. Something you should probably be reporting. They should allow either to send Response to mockError , or to perform the same check in mockRespond that they do in XHRConnection .

Updated (by OP) SetupConnections ()

Current solution

 function setupConnections(backend: MockBackend, options: any): any { backend.connections.subscribe((connection: MockConnection) => { const responseOptions: any = new ResponseOptions(options); const response: any = new Response(responseOptions); // Have to check the response status here and return the appropriate mock // See issue: https://github.com/angular/angular/issues/13690 if (responseOptions.status >= 200 && responseOptions.status <= 299) connection.mockRespond(response); else connection.mockError(response); }); } 
+3
source

Here is my working solution, similar to the above suggestions, but with more clarity:

 it('should log an error to the console on error', async(inject([AjaxService, MockBackend], ( ajaxService: AjaxService, mockBackend: MockBackend) => { service = ajaxService; backend = mockBackend; backend.connections.subscribe((connection: MockConnection) => { const options: any = new ResponseOptions({ body: { error: 'Some strange error' }, status: 404 }); const response: any = new Response(options); connection.mockError(response); }); spyOn(console, 'error'); service.get('/bad').subscribe(res => { console.log(res); // Object{error: 'Some strange error'} }, e => { expect(console.error).toHaveBeenCalledWith('404 - Some strange error'); }); }))); 

Full working help code:

Below are all possible test scenarios. Note. Do not worry about AjaxService . This is my regular shell on the angular http service, which is used as an interceptor.

ajax.service.spec.ts

 import { AjaxService } from 'app/shared/ajax.service'; import { TestBed, inject, async } from '@angular/core/testing'; import { Http, BaseRequestOptions, ResponseOptions, Response } from '@angular/http'; import { MockBackend, MockConnection } from '@angular/http/testing'; describe('AjaxService', () => { let service: AjaxService = null; let backend: MockBackend = null; beforeEach(async(() => { TestBed.configureTestingModule({ providers: [ MockBackend, BaseRequestOptions, { provide: Http, useFactory: (backendInstance: MockBackend, defaultOptions: BaseRequestOptions) => { return new Http(backendInstance, defaultOptions); }, deps: [MockBackend, BaseRequestOptions] }, AjaxService ] }); })); it('should return mocked post data', async(inject([AjaxService, MockBackend], ( ajaxService: AjaxService, mockBackend: MockBackend) => { service = ajaxService; backend = mockBackend; backend.connections.subscribe((connection: MockConnection) => { const options = new ResponseOptions({ body: JSON.stringify({ data: 1 }), }); connection.mockRespond(new Response(options)); }); const reqOptions = new BaseRequestOptions(); reqOptions.headers.append('Content-Type', 'application/json'); service.post('', '', reqOptions) .subscribe(r => { const out: any = r; expect(out).toBe(1); }); }))); it('should log an error to the console on error', async(inject([AjaxService, MockBackend], ( ajaxService: AjaxService, mockBackend: MockBackend) => { service = ajaxService; backend = mockBackend; backend.connections.subscribe((connection: MockConnection) => { const options: any = new ResponseOptions({ body: { error: 'Some strange error' }, status: 404 }); const response: any = new Response(options); connection.mockError(response); }); spyOn(console, 'error'); service.get('/bad').subscribe(res => { console.log(res); // Object{error: 'Some strange error'} }, e => { expect(console.error).toHaveBeenCalledWith('404 - Some strange error'); }); }))); it('should extract mocked data with null response', async(inject([AjaxService, MockBackend], ( ajaxService: AjaxService, mockBackend: MockBackend) => { service = ajaxService; backend = mockBackend; backend.connections.subscribe((connection: MockConnection) => { const options = new ResponseOptions({ }); connection.mockRespond(new Response(options)); }); const reqOptions = new BaseRequestOptions(); reqOptions.headers.append('Content-Type', 'application/json'); service.get('test', reqOptions) .subscribe(r => { const out: any = r; expect(out).toBeNull('extractData method failed'); }); }))); it('should log an error to the console with empty response', async(inject([AjaxService, MockBackend], ( ajaxService: AjaxService, mockBackend: MockBackend) => { service = ajaxService; backend = mockBackend; backend.connections.subscribe((connection: MockConnection) => { const options: any = new ResponseOptions({ body: {}, status: 404 }); const response: any = new Response(options); connection.mockError(response); }); spyOn(console, 'error'); service.get('/bad').subscribe(res => { console.log(res); // Object{error: 'Some strange error'} }, e => { expect(console.error).toHaveBeenCalledWith('404 - {}'); }); // handle null response in error backend.connections.subscribe((connection: MockConnection) => { connection.mockError(); }); const res: any = null; service.get('/bad').subscribe(res, e => { console.log(res); }, () => { expect(console.error).toHaveBeenCalledWith(null, 'handleError method with null error response got failed'); }); }))); }); 

ajax.service.ts

 import { Injectable } from '@angular/core'; import { Http, Response, RequestOptionsArgs, BaseRequestOptions } from '@angular/http'; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/operator/catch'; import 'rxjs/add/operator/map'; import 'rxjs/add/observable/throw'; /** * Wrapper around http, use this for all http operations. * It has centralized error handling as well. * @export * @class AjaxService */ @Injectable() export class AjaxService { /** * Creates an instance of AjaxService. * @param {Http} http * * @memberOf AjaxService */ constructor( private http: Http, ) { } /** * Performs a request with get http method. * * @param {string} url * @param {RequestOptionsArgs} [options] * @returns {Observable<Response>} * * @memberOf AjaxService */ get(url: string, options?: RequestOptionsArgs): Observable<Response> { options = this.getBaseRequestOptions(options); options = this.setHeaders(options); return this.http.get(url, options) .map(this.extractData) .catch(this.handleError); } /** * Performs a request with post http method. * * @param {string} url * @param {*} body * @param {RequestOptionsArgs} [options] * @returns {Observable<Response>} * * @memberOf AjaxService */ post(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> { options = this.getBaseRequestOptions(options); options = this.setHeaders(options); return this.http.post(url, body, options) .map(this.extractData) .catch(this.handleError); } /** * Util function to fetch data from ajax response * * @param {Response} res * @returns * * @memberOf AjaxService */ private extractData(res: Response) { const body = res.json(); const out = body && body.hasOwnProperty('data') ? body.data : body; return out; } /** * Error handler * Future Scope: Put into remote logging infra like into GCP stackdriver logger * @param {(Response | any)} error * @returns * * @memberOf AjaxService */ private handleError(error: Response | any) { let errMsg: string; if (error instanceof Response) { const body = error.json() || ''; const err = body.error || JSON.stringify(body); errMsg = `${error.status} - ${error.statusText || ''}${err}`; } else { errMsg = error.message ? error.message : error.toString(); } console.error(errMsg); return Observable.throw(errMsg); } /** * Init for RequestOptionsArgs * * @private * @param {RequestOptionsArgs} [options] * @returns * * @memberOf AjaxService */ private getBaseRequestOptions(options: RequestOptionsArgs = new BaseRequestOptions()) { return options; } /** * Set the default header * * @private * @param {RequestOptionsArgs} options * @returns * * @memberOf AjaxService */ private setHeaders(options: RequestOptionsArgs) { if (!options.headers || !options.headers.has('Content-Type')) { options.headers.append('Content-Type', 'application/json'); } return options; } } 
+4
source

Source: https://habr.com/ru/post/1013577/


All Articles