rxjsjestjsrace-conditionredux-observable

How to test race condition of a redux observable epic


I have a use case where I need to cancel an Ajax call and do something else in an epic. There's an example in the redux-observable doc which fits my need exactly. However, when I try to test the racing condition in my epic, the "cancelation" doesn't seem to work.

The example code is like:

import { ajax } from 'rxjs/ajax';

const fetchUserEpic = action$ => action$.pipe(
  ofType(FETCH_USER),
  mergeMap(action => race(
    ajax.getJSON(`/api/users/${action.payload}`).pipe(
      map(response => fetchUserFulfilled(response))
    ),
    action$.pipe(
      ofType(FETCH_USER_CANCELLED),
      map(() => incrementCounter()),
      take(1)
    )
  ))
);

My epic has the same structure as the example above, which is like:

initViewsEpic = (action$, state$, { ajaxGet }) => action$
  .ofType(INIT_VIEWS)
  .pipe(
    mergeMap(() => race(
      ajaxGet('/api/views/...')
        .pipe(
          switchMap(response => of(
            initViewsFulFilled(response),
            taskCompleted(INIT_VIEWS),
          )),
          startWith(taskInProgress(INIT_VIEWS)),
          catchError(error => of(
             notification(),
             taskCompleted(INIT_VIEWS),
           )),
        ),
      action$.pipe(
        ofType(INIT_VIEWS_CANCEL),
        map(() => taskCompleted(INIT_VIEWS),
        take(1),
      ),
    )),
  );

And my test is like:

test('should ignore the ajax call response when INIT_VIEWS_CANCEL action is fired', (done) => {
    const action$ = ActionsObservable.of({ type: 'INIT_VIEWS' }, { type: 'INIT_VIEWS_CANCEL' });
    const ajaxGet = () => timer(3000);

   initViewsEpic(action$, state$, { ajaxGet })
      .pipe(toArray()).subscribe((actions) => {
        expect(actions).toEqual([
          {
            type: 'TASK_IN_PROGRESS',
            payload: { id: 'INIT_VIEWS' },
          },
          {
            type: 'TASK_COMPLETED',
            payload: { id: 'INIT_VIEWS' },
          },
        ]);
        done();
      });
  });

I suppose that since the INIT_VIEWS_CANCEL action follows the INIT_VIEWS action synchronously, it should "win" the ajaxGet and there should not be any initViewsFulFilled goes out. But the result of this test always returns initViewsFulFilled as the second output action of my epic (I am using jest to test the epic).

Is there anything I did wrong in my test? If so, how can I test this race condition in a redux-observable epic properly?


Solution

  • I will say I'm going to give a suggestion to test redux observable epics (that's what I've done) -- Use TestScheduler in rxjs/testing. So that we don't have to scratch the head dealing with ascyn things of other test frameworks

    Here is the pseudo-code:

    import { of } from 'rxjs';
    import { delay } from 'rxjs/operators';
    import { ActionsObservable } from 'redux-observable';
    import { TestScheduler } from 'rxjs/testing';
    import { initViewsEpic } from '../actions';
    
    const deepEquals = (actual, expected) => expect(actual).toEqual(expected);
    const createTestScheduler = () => new TestScheduler(deepEquals);
    let ts = {};
    
    beforeEach(() => {
      ts = createTestScheduler();
    });
    
    afterEach(() => {
      ts.flush();
    });
    
    describe('initViewsEpic Epic', () => {
      const state$ = of(...);
    
      let values = {};
    
      beforeEach(() => {
        values = {
          a: { type: 'INIT_VIEWS' },
          b: { type: 'INIT_VIEWS_FULFILLED'},
          c: { type: 'INIT_VIEWS_CANCEL' },
          d: { type: 'TASK_IN_PROGRESS', payload: { id: 'INIT_VIEWS' } },
          e: { type: 'TASK_COMPLETED', payload: { id: 'INIT_VIEWS' } },
        };
      });
    
      test('in case of success') ...
      test('in case of error') ...
    
      test('in case of cancel', () => {
        const source = ActionsObservable.from(ts.createHotObservable('-ac----', values));
        const ajaxGet = () => () =>
          of({ response: 'arbitrary obj' }).pipe(delay(ts.createTime('---|'), ts));
    
        const actual = initViewsEpic(source, state$, { ajaxGet });
    
        ts.expectObservable(actual).toBe('-de---', values);
      });
    });
    
    

    Hope this can help someone who has the same problem as I did.