Angular 2单元测试Observable错误(HTTP)

6
我正在尝试为我的API服务编写单元测试,但在捕获HTTP错误时遇到了一些问题。我正在按照这个指南和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方法中捕获错误,而是在映射中显式地捕获它,那么仍然存在相同的问题。

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

经过一些讨论,该问题被提交。


你确定错误回调函数应该在错误状态码时被调用吗? - Paul Samsotha
我不知道。那些链接中没有一个明确说明这个问题。通过查看源代码,我没有看到任何检查错误状态码的代码。我认为开发人员需要从响应中处理这个问题。 - Paul Samsotha
@peeskillet 我同意,但是HTTP指南非常明确地说明了如何从http.get()中捕获错误。即便如此,请查看我的新更新。如果我手动测试一个正常的响应,仍然会出现相同的问题。 - Jacob Windsor
实际上没关系,这里已经检查过了:https://github.com/angular/angular/blob/master/modules/%40angular/http/src/backends/xhr_backend.ts#L88 - Paul Samsotha
想到了。不过还是谢谢你的检查! - Jacob Windsor
显示剩余2条评论
2个回答

4

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

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服务。

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

}

"import { MockBackend, MockConnection } from '@angular/http/testing' 已经被弃用了,你知道我们应该如何使用 @angular/common/http 吗?" - Winnemucca

3
据我所知,这与单元测试未异步执行有关,因此无法等待错误响应。但是,由于我在beforeEach方法中使用了async()实用程序函数,所以我不知道为什么会出现这种情况。
您需要在测试用例(it)中使用它。 async的作用是创建一个测试区域,在完成测试(或测试区域,例如beforeEach)之前等待所有异步任务完成。
因此,在beforeEach中的async只等待该方法中的异步任务完成,然后退出该方法。但是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 的工作方式不一致,它会检查状态,并通过 nexterror 发送 Response,但是它是相同的 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);

听起来像是一个bug。这是你应该报告的问题。他们应该允许你在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);
    });
}

谢谢。我已经尝试过这个,但仍然得到完全相同的结果。单元测试中的onerror块中没有任何调用。我之前尝试过这个,因为认为是同样的问题,但没有运气。这也不是我参考的指南中所完成的。 - Jacob Windsor
你检查了第一个回调函数吗? - Paul Samsotha
我做了与上面相同的事情,并能够捕获,但是出现了“TypeError: You provided an invalid object where a stream was expected. You can provide an Observable, Promise, Array, or Iterable.”的错误。代码看起来像这样:this.http.get(url, options) .map(res => { return res.json()}) .catch(res => { return res.json(); } }).finally(() => { });在规范中,service.get('geturl').subscribe(() => { });。 我有什么遗漏吗? - NikhilGoud

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接