Search code examples
angularrxjsngrxjasmine-marbles

Testing fail action - marble - ngrx Effects


I've got an issue testing a failed action on my effects.

To give a bit of context here loadProducts effect is executed when the Load action is called. Inside the effect an HTTP request is performed, in case this request is executed successfully the LoadSuccess action is called, otherwise LoadFail is called. Code here bellow

  @Effect()
  loadProducts$ = this.actions$.pipe(
    ofType(productActions.ProductActionTypes.Load),
    mergeMap((action: productActions.Load) =>
      this.productService.getProducts().pipe(
        map((products: Product[]) => (new productActions.LoadSuccess(products))),
        catchError(error => of(new productActions.LoadFail(error)))
      ))
  );

To test this effect I used jest-marbles that is pretty much the same than jasmine-marbles, anyway, I created Load action as a hot observable, my http response as a cold and the default expected outcome.

it('should return a LoadFail action, with an error, on failure', () => {
  const action = new Load();
  const errorMessage = 'Load products fail';
  const outcome = new LoadFail(errorMessage);

  actions$ = hot('-a', { a: action});

  const response = cold('-#|', {}, errorMessage);
  productServiceMock.getProducts = jest.fn(() => response);

  const expected = cold('--(b|)', { b: outcome });

  expect(effects.loadProducts$).toBeObservable(expected);
});

When I run the test throws an error saying my loadProducts observable and the expected outcome does not match.

  ✕ should return a LoadFail action, with an error, on failure (552ms)

Product effects › loadProducts › should return a LoadFail action, with an error, on failure

expect(received).toBeNotifications(expected)

Expected notifications to be:
  [{"frame": 20, "notification": {"error": undefined, "hasValue": true, "kind": "N", "value": {"payload": "Load products fail", "type": "[Product] Load Fail"}}}, {"frame": 20, "notification": {"error": undefined, "hasValue": false, "kind": "C", "value": undefined}}]
But got:
  [{"frame": 20, "notification": {"error": undefined, "hasValue": true, "kind": "N", "value": {"payload": "Load products fail", "type": "[Product] Load Fail"}}}]

Difference:

- Expected
+ Received

  Array [
    Object {
      "frame": 20,
      "notification": Notification {
        "error": undefined,
        "hasValue": true,
        "kind": "N",
        "value": LoadFail {
          "payload": "Load products fail",
          "type": "[Product] Load Fail",
        },
      },
    },
-   Object {
-     "frame": 20,
-     "notification": Notification {
-       "error": undefined,
-       "hasValue": false,
-       "kind": "C",
-       "value": undefined,
-     },
-   },
  ]

I know what the error is but I have no idea how to solve it. I am knew on the marbles testing world


Solution

  • I'd like to explain why it didn't work in the first place.

    As you know, when you're testing observables using marble diagrams, you're not using the real time, but a virtual time. Virtual time can be measured in frames. The value of a frame can vary(e.g 10, 1), but regardless of the value, it's something that helps illustrating the situation you're dealing with.

    For example, with hot(--a---b-c), you describe an observable that will emit the following values: a at 2u, b at 6u and c at 8u(u - units of time).

    Internally, RxJs creates a queue of actions and each action's task is to emit the value that it has been assigned. {n}u describes when the action will do its task.

    For hot(--a---b-c), the action queue would look like this(roughly):

    queue = [
      { frame: '2u', value: 'a' }/* aAction */, 
      { frame: '6u', value: 'b' }/* bAction */, 
      { frame: '8u', value: 'c' }/* cAction */
    ]
    

    hot and cold, when called, will instantiate a hot and cold observable, respectively. Their base class extends the Observable class.

    Now, it's very interesting to see what happens when you're dealing with inner observables, as encountered in your example:

    actions$ = hot('-a', { a: action}); // 'a' - emitted at frame 1
    
    const response = cold('-#|', {}, errorMessage); // Error emitted at 1u after it has been subscribed
    productServiceMock.getProducts = jest.fn(() => response);
    
    const expected = cold('--(b|)', { b: outcome }); // `b` and `complete` notification, both at frame 2
    

    The response observable is subscribed due to a, meaning that the error notification will be emitted at frame of a + original frame. That is, frame 1(a's arrival) + frame1(when the error is emitted) = frame 2.

    So, why did hot('-a') not work ?

    This is because of how mergeMap handles things. When using mergeMap and its siblings, if the source completes but the operator has inner observables that are still active(did not complete yet), the source's complete notification won't be passed along. It will be only when all the inner observables complete as well.

    On the other hand, if all the inner observables complete, but the source didn't, there is no complete notification to be passed along to the next subscriber in the chain. This is why it hadn't worked initially.

    Now, let's see why it does work this way:

    actions$ = hot('-a|', { a: action});
    
    const response = cold('-#|)', {}, errorMessage);
    productServiceMock.getProducts = jest.fn(() => response);
    
    const expected = cold('--(b|)', { b: outcome });
    

    the action's queue would now look like this:

    queue = [
      { frame: '1u', value: 'a' },
      { frame: '2u', completeNotif: true },
    ]
    

    When a is received, the response will be subscribed and because it's an observable created with cold(), its notifications will have to be assigned to actions and put in the queue accordingly.

    After response has been subscribed to, the queue would look like this:

    queue = [
      // `{ frame: '1u', value: 'a' },` is missing because when an action's task is done
      // the action itself is removed from the queue
    
      { frame: '2u', completeNotif: true }, // Still here because the first action didn't finish
      { frame: '2u', errorNotif: true, name: 'Load products fail' }, // ' from '-#|'
      { frame: '3u', completeNotif: true },// `|` from '-#|'
    ]
    

    Notice that if 2 queue actions should be emitted at the same frame, to oldest one will take precedence.

    From the above, we can tell that the source will emit a complete notification before the inner observable emits the error, meaning that when the inner observable will emit the value resulted from catching the error(outcome), the mergeMap will pass along the complete notification.

    Finally, (b|) is needed in cold('--(b|)', { b: outcome }); because the observable which catchError subscribes to, of(new productActions.LoadFail(error))), will emit and complete within the same frame. The current frame holds the value of the current selected action's frame. In this case, is 2, from { frame: '2u', errorNotif: true, name: 'Load products fail' }.