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
Here are some links to more information about marble testing with NgRx: In summary, we can use marble-diagram-like strings to describe an observable stream over time. This enables us to synchronously test asynchronous observable streams. There are two primary functions that we will be using: hot () creates a hot observable stream.
If we do that, the Observable that NgRx Effects subscribes to is going to be replaced by the Observable emitted in the catchError method, and then all future values emitted from action$ are going to be ignored (the new Observable in the error handler is not subscribed to them).
If you've used NgRx before this may look extremely familiar. An action comes in which triggers something like an API call. This call will either succeed or fail and we dispatch a success or failure action as a result. In large NgRx codebases you might have this kind of effect all over the place.
I would therefore highly recommend that if you don’t already do it, you should switch your Effects testing to the Marble tests. It turns out that catchError (and its predecessor, .catch () ), is a tricksy beast, making us think that it will just replace errors with new values, but actually switching entirely to a new Observable on the first error.
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' }
.
I found a way to solve my issue, not sure is the best way to do it, but basically I added a pipe to complete the hot observable. Please let me know if there is any other solution.
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);
});
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With