Angular NgRx Effect errors in Marble Testing: Expected $.length = 0 to equal 2. / Expected $[0] = undefined to equal Object

Viewed 2196

I have an app that uses Angular and NgRx and I am having difficulties to test my Effect, using Marble Testing.

The error that I get is:

Expected $.length = 0 to equal 2.
Expected $[0] = undefined to equal Object({ frame: 10, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }).
Expected $[1] = undefined to equal Object({ frame: 20, notification: Notification({ kind: 'C', value: undefined, error: undefined, hasValue: false }) }).

Here is the Effect:

@Injectable()
export class OrderLogisticStatusEffects {
  loadOrdersLogisticStatus$ = createEffect(() =>
    this.actions$.pipe(
      ofType(LOAD_ORDERS_LOGISTIC_STATUS),
      withLatestFrom(this.store$.pipe(select(orderLogisticsStatusPollingIntervalSelector))),
      switchMap(([action, pollingInterval]) =>
        timer(0, pollingInterval).pipe(
          withLatestFrom(this.store$.pipe(select(selectedCompanySelector))),
          switchMap(([timerNum, company]) => this.loadOrdersLogisticStatus(pollingInterval, company))
        )
      )
    )
  );

  constructor(private actions$: Actions, private orderLogisticStatusService: OrderLogisticStatusService, private store$: Store<AppState>) {}

  private loadOrdersLogisticStatus(
    pollingInterval: number,
    company: Company
  ): Observable<LoadOrderLogisticStatusSuccess | LoadOrderLogisticStatusFail> {
    if (!company?.logisticsToken) {
      return of(new LoadOrderLogisticStatusFail(new Error('No company selected')));
    }

    this.orderLogisticStatusService.getOrdersStatus(company.logisticsToken).pipe(
      timeout(pollingInterval),
      map((result) => new LoadOrderLogisticStatusSuccess(result)),
      catchError((error) => {
        if (error.name === 'TimeoutError') {
          console.warn('Timeout error while loadin logistic status service', error);
        } else {
          console.error('Error loading order logistic status', error);
          Sentry.captureException(error);
        }

        return of(new LoadOrderLogisticStatusFail(error));
      })
    );
  }
}

And here is my test:

fdescribe('Order Logistic Status Effect', () => {
  let actions$: Observable<Action>;
  let effects: OrderLogisticStatusEffects;

  describe('With a selected company', () => {
    beforeEach(() => {
      const mockState = {
        ordersLogisticStatus: {
          pollingInterval: 10,
        },
        company: {
          selectedCompany: {
            logisticsToken: 'ey.xxxx.yyyy',
          },
        },
      };

      TestBed.configureTestingModule({
        providers: [
          { provide: OrderLogisticStatusService, useValue: jasmine.createSpyObj('orderLogisticsStatusServiceSpy', ['getOrdersStatus']) },
          OrderLogisticStatusEffects,
          provideMockActions(() => actions$),
          provideMockStore({
            selectors: [
              {
                selector: orderLogisticsStatusPollingIntervalSelector,
                value: 30,
              },
              {
                selector: selectedCompanySelector,
                value: {
                  logisticsToken: 'ey.xxxx.yyy',
                },
              },
            ],
          }),
        ],
      });

      effects = TestBed.inject<OrderLogisticStatusEffects>(OrderLogisticStatusEffects);
    });

    it('should sucessfully load the orders logistics status', () => {
      const service: jasmine.SpyObj<OrderLogisticStatusService> = TestBed.inject(OrderLogisticStatusService) as any;
      service.getOrdersStatus.and.returnValue(cold('-a|', { a: mockData }));

      actions$ = hot('a', { a: new LoadOrdersLogisticStatus() });
      const expected = hot('-a|', {
        a: new LoadOrderLogisticStatusSuccess(mockData),
      });

      getTestScheduler().flush();
      expect(effects.loadOrdersLogisticStatus$).toBeObservable(expected);
    });
  });
});

const mockData = {
  1047522: {
    status: 0,
    partner: {
      id: 1,
    },
    eta: '2020-06-09 10:00',
    pickupEta: '2020-06-09 12:00',
  },
};

The problem seems to be happening with my service mock. Even though I configured it to return a cold observable, it seems that it is returning undefined.

Can anyone help me?

Stack Blitz: https://stackblitz.com/edit/angular-effects-test

2 Answers

StackBlitz.


There were a few problems:

Firstly, the loadOrdersLogisticStatus was missing a return:

loadOrdersLogisticStatus (/* ... */) {
  return this.orderLogisticStatusService.getOrdersStatus(/* ... */)
}

Then, I found out that jasmine-marbles does not set the AsyncScheduler.delegate automatically, as opposed to TestScheduler.run:

 run<T>(callback: (helpers: RunHelpers) => T): T {
  const prevFrameTimeFactor = TestScheduler.frameTimeFactor;
  const prevMaxFrames = this.maxFrames;

  TestScheduler.frameTimeFactor = 1;
  this.maxFrames = Infinity;
  this.runMode = true;
  AsyncScheduler.delegate = this;

  /* ... */
}

This is important because when using marbles, everything is synchronous. But in your implementation, there was a timer(0, pollingInterval) observable, which, by default, uses AsyncScheduler. Without setting AsyncScheduler.delegate, we'd be having asynchronous actions, which I think it was the main problem.

So, in order to set the delegate property, I've added this line in beforeEach():

AsyncScheduler.delegate = getTestScheduler();

Finally, I think there was a small problem with your assertions. Your effect property does not seem to ever complete, and you're also using timer(0, pollingInterval). So what I think you could do now is to add the take(N) operator in order to test against N emissions:

it("should sucessfully load the orders logistics status", () => {
  const service: jasmine.SpyObj<OrderLogisticStatusService> = TestBed.inject(OrderLogisticStatusService) as any;
  service.getOrdersStatus.and.callFake(() => cold('-a|', { a: mockData }));

  actions$ = hot('a', { a: new LoadOrdersLogisticStatus() });
  const expected = hot('-a--(b|)', {
    a: new LoadOrderLogisticStatusSuccess(mockData),
    b: new LoadOrderLogisticStatusSuccess(mockData),
  });

  expect(effects.loadOrdersLogisticStatus$.pipe(take(2))).toBeObservable(expected);
});

'-a--(b|)' - a is sent at the 10th frame, and b + complete notification(due to take) are sent at 40th frame, because the pollingInterval is 30 and the current frame would be 10 when the second notification(b) would be scheduled.

Andrei's solution did work for the Stack Blitz, but it didn't work in my own environment, I think it is because of some import mix up. But the idea behind it: that the TestScheduler for the jasmine-marbles does not replace the AsyncScheduler for the RxJs is correct. And this was my problem (besides forgetting to add a returnstatement in the loadOrdersLogisticStatus function).

So to make everything work I had to do two things:

First I had to manually set the effect's scheduler as the TestScheduler. When I did this, I was finally able to see that the timer observable was emitting results and not undefined as before.

However I then had another problem. The timer wouldn't stop emitting, so I got a long error stream like this:

Expected $.length = 26 to equal 2.
Unexpected $[2] = Object({ frame: 60, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[3] = Object({ frame: 90, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[4] = Object({ frame: 120, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[5] = Object({ frame: 150, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[6] = Object({ frame: 180, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[7] = Object({ frame: 210, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[8] = Object({ frame: 240, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.
Unexpected $[9] = Object({ frame: 270, notification: Notification({ kind: 'N', value: LoadOrderLogisticStatusSuccess({ payload: Object({ 1047522: Object({ status: 0, partner: Object({ id: 1, slug: 'loggi' }), eta: '2020-06-09 10:00', pickupEta: '2020-06-09 12:00' }) }), type: 'load-order-logistic-status-success' }), error: undefined, hasValue: true }) }) in array.

The first two values were ok, but the other ones weren't. So the second thing I did was use a takeUntil operator to unsubscribe from the timer one the test was done (I am not sure if this was the best way to do it, because I must never use this operator outside of the test, otherwise my polling will stop).

Here is the final working version of the code:

Effect:

@Injectable()
export class OrderLogisticStatusEffects {
  loadOrdersLogisticStatus$ = createEffect(() => ({ scheduler = asyncScheduler, stopTimer = EMPTY } = {}) =>
    this.actions$.pipe(
      ofType(LOAD_ORDERS_LOGISTIC_STATUS),
      withLatestFrom(this.store$.pipe(select(orderLogisticsStatusPollingIntervalSelector))),
      switchMap(([action, pollingInterval]) =>
        timer(0, pollingInterval, scheduler).pipe(
          takeUntil(stopTimer),
          withLatestFrom(this.store$.pipe(select(selectedCompanySelector))),
          switchMap(([timerNum, company]) => this.loadOrdersLogisticStatus(pollingInterval, company))
        )
      )
    )
  );

  constructor(private actions$: Actions, private orderLogisticStatusService: OrderLogisticStatusService, private store$: Store<AppState>) {}

  private loadOrdersLogisticStatus(
    pollingInterval: number,
    company: Company
  ): Observable<LoadOrderLogisticStatusSuccess | LoadOrderLogisticStatusFail> {
    if (!company?.logisticsToken) {
      return of(new LoadOrderLogisticStatusFail(new Error('No company selected')));
    }

    return this.orderLogisticStatusService.getOrdersStatus(company.logisticsToken).pipe(
      timeout(pollingInterval),
      map((result) => new LoadOrderLogisticStatusSuccess(result)),
      catchError((error) => {
        if (error.name === 'TimeoutError') {
          console.warn('Timeout error while loadin logistic status service', error);
        } else {
          console.error('Error loading order logistic status', error);
          Sentry.captureException(error);
        }

        return of(new LoadOrderLogisticStatusFail(error));
      })
    );
  }
}

Test:

fdescribe('Order Logistic Status Effect', () => {
  let actions$: Observable<Action>;
  let effects: OrderLogisticStatusEffects;

  describe('With a selected company', () => {
    beforeEach(() => {
      TestBed.configureTestingModule({
        providers: [
          { provide: OrderLogisticStatusService, useValue: jasmine.createSpyObj('orderLogisticsStatusServiceSpy', ['getOrdersStatus']) },
          OrderLogisticStatusEffects,
          provideMockActions(() => actions$),
          provideMockStore({
            selectors: [
              {
                selector: orderLogisticsStatusPollingIntervalSelector,
                value: 30,
              },
              {
                selector: selectedCompanySelector,
                value: {
                  logisticsToken: 'ey.xxxx.yyy',
                },
              },
            ],
          }),
        ],
      });

      effects = TestBed.inject<OrderLogisticStatusEffects>(OrderLogisticStatusEffects);
    });

    it('should sucessfully load the orders logistics status', () => {
      const service = TestBed.inject(OrderLogisticStatusService) as jasmine.SpyObj<OrderLogisticStatusService>;
      service.getOrdersStatus.and.callFake(() => cold('a|', { a: mockData }));

      actions$ = hot('a', { a: new LoadOrdersLogisticStatus() });
      const expected = hot('a--b', {
        a: new LoadOrderLogisticStatusSuccess(mockData),
        b: new LoadOrderLogisticStatusSuccess(mockData),
      });

      const stopTimer = hot('----a', { a: 'stop' });

      const testScheduler = getTestScheduler();
      expect(effects.loadOrdersLogisticStatus$({ scheduler: testScheduler, stopTimer })).toBeObservable(expected);
    });
  });
});

const mockData = {
  1047522: {
    status: 0,
    partner: {
      id: 1,
      slug: 'loggi',
    },
    eta: '2020-06-09 10:00',
    pickupEta: '2020-06-09 12:00',
  },
};

Related