Search code examples
unit-testingrxjstakeretry-logic

Writing tests for RxJS that uses retryWhen operator (understanding difference from retry operator)


I'm trying to write tests for the following function that uses retryWhen operator:

// some API I'm using and mocking out in test
import { geoApi } from "api/observable";

export default function retryEpic(actions$) {
  return actions$.pipe(
    filter(action => action === 'A'),
    switchMap(action => {
      return of(action).pipe(
        mergeMap(() => geoApi.ipLocation$()),
        map(data => ({ data })),
        retryWhen(errors => {
          return errors.pipe(take(2));
        }),
      );
    }),
  );
}

The code is supposed to perform a request to some remote API geoApi.ipLocation$(). If it gets an error, it retries 2 times before giving up.

I have written the following test code that uses Jest and RxJS TestScheduler:

function basicTestScheduler() {
  return new TestScheduler((actual, expected) => {
    expect(actual).toEqual(expected);
  });
}

const mockApi = jest.fn();
jest.mock('api/observable', () => {
  return {
    geoApi: {
      ipLocation$: (...args) => mockApi(...args),
    },
  };
});

describe('retryEpic()', () => {
  it('retries fetching 2 times before succeeding', () => {
    basicTestScheduler().run(({ hot, cold, expectObservable, expectSubscriptions }) => {
      const actions$ = hot('-A');

      // The first two requests fail, third one succeeds
      const stream1 = cold('-#', {}, new Error('Network fail'));
      const stream2 = cold('-#', {}, new Error('Network fail'));
      const stream3 = cold('-r', { r: 123 });

      mockApi.mockImplementationOnce(() => stream1);
      mockApi.mockImplementationOnce(() => stream2);
      mockApi.mockImplementationOnce(() => stream3);

      expectObservable(retryEpic(actions$)).toBe('----S', {
        S: { data: 123 },
      });

      expectSubscriptions(stream1.subscriptions).toBe('-^!');
      expectSubscriptions(stream2.subscriptions).toBe('--^!');
      expectSubscriptions(stream3.subscriptions).toBe('---^');
    });
  });
});

This test fails.

However, when I replace retryWhen(...) with simply retry(2), then the test succeeds.

Looks like I don't quite understand how to implement retry with retryWhen. I suspect this take(2) is closing the stream and kind of preventing everything from continuing. But I don't quite understand it.

I actually want to write some additional logic inside retryWhen(), but first I need to understand how to properly implement retry() with retryWhen(). Or perhaps that's actually not possible?

Additional resources

My implementation of retryWhen + take was based on this SO answer:

Official docs:


Solution

  • You can use retryWhen for those two purposes, one to have your logic in it and the second is the retry numbers you'd like to give it (no need to use retry operator):

    // some API I'm using and mocking out in test
    import { geoApi } from "api/observable";
    
    export default function retryEpic(actions$) {
      return actions$.pipe(
        filter(action => action === 'A'),
        switchMap(action => {
          return of(action).pipe(
            mergeMap(() => geoApi.ipLocation$()),
            map(data => ({ data })),
            retryWhen(errors =>
              errors.pipe(
                mergeMap((error, i) => {
                  if (i === 2) {
                    throw Error();
                  }
                  // return your condition code
                })
              )
            )
          )
        }),
      );
    }
    

    Here is a simple DEMO of that.

    As for understanding this logic:

    retryWhen and retry operators, according to the Official docs you've referenced:

    resubscribing to the source Observable (if no error or complete executes)

    This is why you can't pipe retry and retryWhen together. You can say that these operators are a chain breakers...