Angular 2 个单元测试可观察到的错误 (HTTP)

Angular 2 Unit Testing Observable Errors (HTTP)

我正在尝试为我的 API 服务编写单元测试,但在捕获 HTTP 错误时遇到了一些问题。我正在关注此 guide 以及 Angular2 文档,因为该指南在某些次要领域(稍微)已经过时了。

除服务抛出错误的单元测试(由于错误 HTTP 状态代码)外,所有单元测试均通过。我可以通过注销 response.ok 来判断这一点。从我读到的内容来看,这与单元测试不异步执行有关,因此,不等待错误响应。但是,我不知道为什么会出现这种情况,因为我在 beforeEach 方法中使用了 async() 效用函数。

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

当我检查第一个回调时,response.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

经过一番讨论this issue提交

From what i've read this has something to do with the unit tests not executing asynchronously, hence, not waiting for the error response. However, I have no idea why this is the case here since I have used the async() utility function in the beforeEach method

您需要在测试用例中使用它(it)。 async 所做的是创建一个测试区域,等待所有异步任务完成后再完成测试(或测试区域,例如 beforeEach)。

所以 beforeEach 中的 async 只是在等待方法中的异步任务完成后才退出。但是 it 也需要同样的东西。

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

}))

更新

除了缺少 async 之外,MockConnection. If you look at the 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 的工作方式不一致,它检查状态,并通过 nexterror 发送 Response,但相同的是 [=29] =]

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

对我来说这听起来像个错误。你可能应该报告的事情。他们应该允许您在 mockError 中发送 Response 或在 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);
    });
}

这是我的工作解决方案,与上述建议类似但更清晰:

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

  })));

Reference full working code:

以下是所有可能的测试场景。 注意:不要担心 AjaxService。这是我在 angular http 服务上的自定义包装器,用作拦截器。

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

}