简体   繁体   中英

Unexpected toHaveBeenCalled on catchError (RxJS)

I am using the Angular 6 Tour of Heroes application and am attempting to write unit tests for HeroService.getHeroes() .

The HeroService is defined as:

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';

import { Hero } from './hero';
import { MessageService } from './message.service';

const httpOptions = {
  headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};

@Injectable({ providedIn: 'root' })
export class HeroService {

  private heroesUrl = 'api/heroes';  // URL to web api

  constructor(
    private http: HttpClient,
    private messageService: MessageService) { }

  /** GET heroes from the server */
  getHeroes (): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        tap(heroes => this.log('fetched heroes')),
        catchError(this.handleError('getHeroes', []))
      );
  }

 ...

  /**
   * Handle Http operation that failed.
   * Let the app continue.
   * @param operation - name of the operation that failed
   * @param result - optional value to return as the observable result
   */
  private handleError<T> (operation = 'operation', result?: T) {
    return (error: any): Observable<T> => {

      // TODO: send the error to remote logging infrastructure
      console.error(error); // log to console instead

      // TODO: better job of transforming error for user consumption
      this.log(`${operation} failed: ${error.message}`);

      // Let the app keep running by returning an empty result.
      return of(result as T);
    };
  }

  /** Log a HeroService message with the MessageService */
  private log(message: string) {
    this.messageService.add(`HeroService: ${message}`);
  }
}

My unit tests are:

import { TestBed, inject } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { HeroService } from './hero.service';
import { MessageService } from './message.service';
import { Hero } from './hero';

const mockData = [
  { id: 1, name: 'Hulk' },
  { id: 2, name: 'Thor' },
  { id: 3, name: 'Iron Man' }
] as Hero[];

describe('Hero Service', () => {

  let heroService;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {

    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule
      ],
      providers: [HeroService, MessageService]
    });
    httpTestingController = TestBed.get(HttpTestingController);

    this.mockHeroes = [...mockData];
    this.mockHero = this.mockHeroes[0];
    this.mockId = this.mockHero.id;
    heroService = TestBed.get(HeroService);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('should be created', () => {
    expect(heroService).toBeTruthy();
  });

  describe('getHeroes', () => {

    it('should return mock heroes', () => {
      spyOn(heroService, 'handleError');
      spyOn(heroService, 'log');

      heroService.getHeroes().subscribe(
        heroes => expect(heroes.length).toEqual(this.mockHeroes.length),
        fail
      );

      const req = httpTestingController.expectOne(heroService.heroesUrl);
      expect(req.request.method).toEqual('GET');
      req.flush(this.mockHeroes);

      expect(heroService.handleError).not.toHaveBeenCalled();
      expect(heroService.log).toHaveBeenCalledTimes(1);
    });
  });
});

The tests are failing with:

来自 Karma 的错误消息

The failure is unexpected though as it appears that HeroService.handleError is indeed being called, which is not the case outside the tests. Why is HeroService.handleError being called during the test execution and how should I correct my unit tests?

Your writing the tests slightly wrong the test should be more like this(note the spy references logSpy and errorSpy ).

it('should return mock heroes', () => {
  const errorSpy = spyOn(heroService, 'handleError').and.callThrough();
  const logSpy = spyOn(heroService, 'log');

  heroService.getHeroes().subscribe(
    (heroes: Hero[]) => {
      expect(heroes.length).toEqual(this.mockHeroes.length);
    }
  );

  const req = httpTestingController.expectOne(heroService.heroesUrl);
  //console.log(req);
  expect(req.request.method).toEqual('GET');
  req.flush(this.mockHeroes);

  expect(errorSpy).not.toHaveBeenCalled();
  expect(logSpy).toHaveBeenCalledTimes(1);
});

Here's a stackblitz showing the test in action, ENJOY!

it(`should get heroes`, () => {
    const handleErrorSpy = spyOn<any>(heroService, 'handleError').and.callThrough();
    const logSpy = spyOn<any>(heroService, 'log').and.callThrough();
    const addSpy = spyOn(messageService, 'add').and.callThrough();

    heroService.getHeroes().subscribe( (heroes: Hero[]) => {
      expect(heroes.length).toEqual(3);
    });

    const request = httpMock.expectOne( `api/heroes`, 'call to getHeroes');
    expect(request.request.method).toBe('GET');
    request.flush(mockHeroes);

    expect(handleErrorSpy).toHaveBeenCalledTimes(1);
    expect(logSpy).toHaveBeenCalledTimes(1);
    expect(addSpy).toHaveBeenCalledTimes(1);
    expect(handleErrorSpy).toHaveBeenCalledWith('getHeroes', [  ]);
    expect(logSpy).toHaveBeenCalledWith('fetched heroes');
    expect(addSpy).toHaveBeenCalledWith('HeroService: fetched heroes');
  });

I found the solution. In getHeroes() in hero.service.ts change the definition of the catch error as follows:

...
getHeroes (): Observable<Hero[]> {
    return this.http.get<Hero[]>(this.heroesUrl)
      .pipe(
        tap(heroes => this.log('fetched heroes')),
        catchError(() => this.handleError('getHeroes', []))
      );
  }
...

I found it thanks to that other question catchError always gets called in HTTP unit testing

EDIT: With the previous solution the test that checks the case that the request returns an error is broken. That test is not in the original answer but it's part of the file hero.service.spec.ts:

it('should turn 404 into a user-friendly error', () => {
  ...
});

To make the two tests runing as expected, the code in every catchError from hero.service.ts has to be modified as follows (example from getHeroes function):

...
catchError(err => this.handleError(err, 'getHeroes', []))
...

and then the handleError function as follows:

private handleError<T>(err, operation = 'operation', result?: T) {
    console.log('handleError called. Operation:', operation, 'result:', result);
    console.error(err);
    this.log(`${operation} failed: ${err.message}`);
    return of(result as T);
}

TL;DR Get rid of the annonimous function inside handleError.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM