angularangular-http-interceptorsangular-test

How to test an Angular interceptor with a retry strategy?


This is a simplified version of my Angular 18 error interceptor. It will retry requests that produced an error to make sure it was not a fluke (please don't ask!). This works, however I am running into issues trying to write unit tests for this

let apiService: ApiService;

const genericRetryStrategy = (): ((e: HttpErrorResponse, i: number) => Observable<number>) => {
  const maxRetryAttempts = 1;
  const scalingDuration = 200;
  const excludedStatusCodes = [401, 403, 503];
  return (error: HttpErrorResponse, i: number): Observable<number> => {
    const retryAttempt = i + 1;
    if (retryAttempt > maxRetryAttempts || excludedStatusCodes.some((code) => code === error.status)) {
      return throwError(() => error);
    }
    console.info(`Attempt ${retryAttempt.toString()}: retrying in ${(retryAttempt * scalingDuration).toString()}ms`);
    // retry after 1s, 2s, etc...
    return timer(retryAttempt * scalingDuration);
  };
};

const catchAll$ = (code: string, url: string, error: HttpErrorResponse): Observable<never> => {
  console.error('Logging Error', code, error.message);
  apiService.logError$(msg, error.error, url).subscribe({
    error: () => {
      console.error('Unable to log error!');
    },
  });

  return throwError(() => new Error(msg));
};

export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
  apiService = inject(ApiService);

  return next(req).pipe(
    retry({ delay: genericRetryStrategy() }),
    catchError((error: HttpErrorResponse) => {
      return catchAll$(code, req.url, error);
    }),
  );
};

I am using the jasmine-auto-spies package (which is great!) - but I am not sure exactly how to test this logic. Here is what I have so far

const interceptor: HttpInterceptorFn = (req, next) => TestBed.runInInjectionContext(() => httpErrorInterceptor(req, next));
let httpClient: HttpClient;
let httpMock: HttpTestingController;

beforeEach(() => {
  TestBed.configureTestingModule({
    imports: [],
    providers: [
      provideHttpClient(withInterceptors([interceptor])),
      provideHttpClientTesting(),
      {
        provide: ApiService,
        useValue: createSpyFromClass(ApiService, {
          methodsToSpyOn: ['logError$'],
        }),
      },
    ],
  });

  httpClient = TestBed.inject(HttpClient);
  httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
  // Assert that no other requests were made after each test completes
  httpMock.verify();
});

describe('Error Retrying', () => {
  it('should retry a 500 error only once with a slight delay', fakeAsync(() => {
      httpClient.get('http://server.biz/api/whatever').subscribe({
        next: () => {
          throw new Error('This should not have been successful!');
        },
        error: (err) => {
          console.log('error 1', err);
        },
      });
      const req1 = httpMock.expectOne('http://server.biz/api/whatever');
      req1.flush({}, { status: 500, statusText: 'Server Error 1' });
      tick(650);

      expect(console.info).toHaveBeenCalledWith(`Attempt 1: retrying in 200ms`);
      expect(console.info).toHaveBeenCalledWith(`Attempt 2: retrying in 400ms`);

      // ???
    }));

  it('should NOT retry a 401 error', () => {
    httpClient.get('http://example.com/api/whatever').subscribe();
    const req1 = httpMock.expectOne('http://server.biz/api/whatever');
    req1.flush('message here', { status: 401, statusText: 'Unauthorized' });

    // ???
  });

  it('should NOT retry a 403 error', () => {
    // ???
  });

  it('should NOT retry a 503 error', () => {
    // ???
  });
});

How do I test this so that I can verify that 2 requests were made, and the 2nd one on a delay?


Solution

  • Turns out my main mistake was assuming that RxJS's retry count was zero indexed! I had const retryAttempt = i + 1; which was making the first attempt into 2 - oops!

    With that fixed in my code, the test became very straightforward

    it('should retry a 500 error only once with a slight delay', fakeAsync(() => {
      httpClient.get('http://server.biz/api/whatever').subscribe({
        next: () => {
          fail('This should not have been successful!');
        },
        error: (err: HttpErrorResponse) => {
          expect(err.status).toEqual(500);
          expect(err.statusText).toEqual('Server Error 2');
        },
      });
    
      const req1 = httpMock.expectOne('http://server.biz/api/whatever');
      req1.flush('response body', { status: 500, statusText: `Server Error 1` });
      tick(200);
    
      const req2 = httpMock.expectOne('http://server.biz/api/whatever');
      req2.flush('response body', { status: 500, statusText: `Server Error 2` });
    
      expect(console.info).toHaveBeenCalledOnceWith(`Attempt 1: retrying in 200ms`);
    }));
    
    it('should NOT retry a 401 error', () => {
      httpClient.get('http://server.biz/api/whatever').subscribe({
        next: () => {
          fail('This should not have been successful!');
        },
        error: (err: HttpErrorResponse) => {
          expect(err.status).toEqual(401);
          expect(err.statusText).toEqual('Unauthorized');
        },
      });
    
      const req1 = httpMock.expectOne('http://server.biz/api/whatever');
      req1.flush('response body', { status: 401, statusText: `Unauthorized` });
    
      expect(console.info).not.toHaveBeenCalled();
    });