Угловое 2 единичное тестирование наблюдаемых ошибок (HTTP)

Я пытаюсь написать модульные тесты для моей службы API, но у меня есть некоторые проблемы с перехватом ошибок HTTP. Я следую этому руководству вместе с Angular2 docs, так как руководство (немного) устарело в некоторых незначительных областях.

Все модульные тесты проходят отдельно от тех, где служба выдает ошибку (из-за кода состояния HTTP ошибки). Я могу сказать это, выйдя из response.okсистемы . Из того, что я прочитал, это связано с тем, что модульные тесты не выполняются асинхронно, следовательно, не ждут ответа на ошибку. Однако я понятия не имею, почему это происходит здесь, так как я использовал функцию async()полезности в beforeEachметоде.

Служба API

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

Модульный тест ошибок

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

Обновление 1

когда я проверяю первый обратный вызов, ответ.ok не определен. Это наводит меня на мысль, что в утилите что-то не такsetupConnections.

    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.");
    }));

Обновление 2

Если вместо того, чтобы ловить ошибки в get методе я делаю это явно в map, то все еще есть та же проблема.

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

Обновление 3

После некоторого обсуждения этот вопрос представлен

2 ответа

  1. Из того, что я прочитал, это связано с тем, что модульные тесты не выполняются асинхронно, следовательно, не ждут ответа на ошибку. Однако я понятия не имею, почему это происходит здесь, так как я использовал функцию async()полезности в beforeEachметоде

    Вы должны использовать его в тестовом случае (the it). Что asyncделает, так это создает тестовую зону, которая ожидает завершения всех асинхронных задач перед завершением теста (или тестовой области, например beforeEach).

    Таким asyncобразом, in beforeEachтолько ждет асинхронных задач для завершения в методе перед выходом из него. Но itтакже нуждается в том же самом.

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

    ОБНОВЛЕНИЕ

    Помимо отсутствующегоasync, кажется, есть ошибка с MockConnection. Если посмотретьmockRespond, то он всегда звонитnext, не принимая во внимание код состояния

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

    У них есть mockError(Error)метод, который вызывает error

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

    но этот звонок не позволит вам пройти а Response. Это несовместимо с тем, как XHRConnectionработает real, который проверяет статус и отправляет Responseлибо через nextor error, но является тем же самым 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);
    

    Похоже на жука. То, что вы, вероятно, должны сообщить. Они должны позволить вам либо отправить ResponsemockErrorили сделать ту же регистрацию, mockRespondчто и они XHRConnection.

    Обновлено (OP) SetupConnections()

    Текущее решение

    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);
        });
    }
    
  2. Вот мое рабочее решение, которое похоже на вышеуказанные предложения, но с большей ясностью:

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

    Ссылка полный рабочий код:

    Ниже приведены все возможные сценарии тестирования.
    Примечание: не беспокойтесь о AjaxService . Это моя пользовательская оболочка на angular http service, которая используется в качестве перехватчика.

    Аякс.услуга.спекуляция.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');
        });
    
      })));
    
    });
    

    Аякс.услуга.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;
      }
    
    }