How to Test Five Common NgRx Effect Patterns
Jo Hanna Pearce
Posted on May 8, 2020
- 0. Test Harness Setup
- 1. Non-Dispatching Tap Effect
- 2. Dispatching SwitchMap Effect
- 3. Multi-Dispatch Effect
- 4. Store Dependent Effect
- 5. Timed Dispatch Effect
⚠️ Requirements
I'm going to assume that you know something about Angular development with TypeScript and at least a little about the NgRx library and the Redux pattern. You might gain some insight from reading through these patterns if you're at the beginning of your journey with these technologies but I don't intend this to be introductory.
I don't necessarily expect this article to be read from start to end. Consider it reference material, which is why I've linked the patterns at the start.
🤯 Introduction
I've been using NgRx with Angular for a few years now and yet still every time I come to testing effects, my mind will often go blank. It's not that tests for effects are particularly arcane, I think it comes down to cognitive load and the Redux-style pattern itself. We know that there's a limited amount of things we can process at any one time, and there is already so much going on in my head trying to manage actions, reducers and selectors, not to mention the complexities of understanding RxJS pipes that trying to cram testing into my head on top of that just causes my brain to stall.
One way I try to solve this problem is by having working template examples to hand.
📋 Copy/Paste Driven Development
A lot of people deride this kind of technique as programming without thinking, but you know what? I'm ok with that. I don't want to have to think about what I'm writing all the time. Sometimes, I know the overall shape of what I need to build. I know which pieces I need to put together, but faffing around with the intricacies of how I do that can be a distraction.
Think back to learning about the ternary operator for example:
const x = y ? 1 : 0;
How long was it before that started to feel natural? (If it even does?) When I started programming, that felt like a little bit of extra complexity that I didn't need. I'd often have to look up how it was used elsewhere in the code to confirm that I was using it correctly!
Having reference code to hand that you know functions correctly is extremely useful, and not just for the novice programmer. You can copy that code and then start modifying it. You know that you're starting from correct behaviour and you don't have to question everything about how you're writing the code, just the pieces you're changing.
This isn't a strategy that's going to work for everything, but I find that when using NgRx (and reactive programming in general) it can be extremely useful as you find yourself writing very similar code over and over again.
If you want to refer to some working code while checking out these patterns, I created a workspace here: https://github.com/jdpearce/ngrx-effects-patterns
0. Test Harness Setup
The workspace I created uses Jest but you could just as easily use Jasmine for testing. Much of the code would be similar except for the spies. I also use jasmine-marbles for Observable testing in most cases, but I won't use any particularly complicated syntax, I use it in the most basic way possible where I can get away with it.
Most effects spec files will be initially set up as follows (imports are omitted for brevity) :
describe('ThingEffects', () => {
let actions: Observable<any>;
// These are the effects under test
let effects: ThingEffects;
let metadata: EffectsMetadata<ThingEffects>;
// Additional providers - very basic effects tests may not even need these
let service: ThingService;
let store: MockStore<fromThings.ThingsPartialState>;
beforeEach(async(() => {
const initialState = {
// You can provide entirely different initial state here
// it is assumed that this one is imported from the reducer file
[fromThings.THINGS_FEATURE_KEY]: fromThings.initialState,
};
TestBed.configureTestingModule({
providers: [
ThingEffects,
ThingService,
provideMockActions(() => actions))
provideMockStore({ initialState: initialAppState }),
],
});
effects = TestBed.inject(ThingEffects);
metadata = getEffectsMetadata(effects);
service = TestBed.inject(ThingService);
store = TestBed.inject(Store) as MockStore<fromThings.ThingsPartialState>;
}));
});
This should look like a standard Angular test harness but without any component under test. provideMockActions and provideMockStore are crucial for helping us test effects. It was truly the dark times before these existed.
1. Non-Dispatching Tap Effect
performThingAction$ = createEffect(
() =>
this.actions$.pipe(
ofType(ThingActions.performThingAction),
tap(() => this.thingService.performAction())
),
{ dispatch: false }
);
This is an effect that only does one thing. It calls a service when receiving a particular action. We use tap here because we don't want to modify the stream in any way. We could change the stream however we like because NgRx isn't going to pay attention to the output, but it's good practice to leave the stream alone unless we have some reason to change it.
1.1 Testing for non-dispatch
All effects have metadata attached and one of the pieces of metadata is whether or not we expect that effect to dispatch another action.
We can test this by looking at the metadata directly :
it('should not dispatch', () => {
expect(metadata.performThingAction$).toEqual(
expect.objectContaining({
dispatch: false,
})
);
});
1.2 Testing the service call is made
it('should call the service', () => {
// set up the initial action that triggers the effect
const action = ThingActions.performThingAction();
// spy on the service call
// this makes sure we're not testing the service, just the effect
jest.spyOn(service, 'performAction');
// set up our action list
actions = hot('a', { a: action });
// check that the output of the effect is what we expect it to be
// (by doing this we will trigger the service call)
// Note that because we don't transform the stream in any way,
// the output of the effect is the same as the input.
expect(effects.performThingAction$).toBeObservable(cold('a', { a: action }));
// check that the service was called
expect(service.performAction).toHaveBeenCalled();
});
⚠️ NB - If we want to cover all the bases we might write another test to check that this effect only fires when
ThingActions.performThingAction()
is emitted. I don't tend to do this, as it's just testing whether NgRxofType
works and whether you've remembered to add it. However, if this is the kind of thing you find you or your team are missing often, feel free to add that test and bring the lack of it up in code reviews.
2. Dispatching SwitchMap Effect
getThings$ = createEffect(() =>
this.actions$.pipe(
ofType(ThingActions.getThings),
switchMap(() =>
this.thingService.getThings().pipe(
map((things) => ThingActions.getThingsSuccess({ things })),
catchError((error) => of(ThingActions.getThingsFailure({ error })))
)
)
)
);
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.
⚠️ NB - Depending on what dispatches the initial
getThings
action, you may want to use concatMap instead here. This is something that would probably be more important for a call which updated the backend rather than simply fetched data.
2.1 Successful service call
it('should get the items and emit when the service call is successful', () => {
// set up the initial action that triggers the effect
const action = ThingActions.getThings();
// set up our dummy list of things to return
// (we could create real things here if necessary)
const things = [];
// spy on the service call and return our dummy list
jest.spyOn(service, 'getThings').mockReturnValue(of(things));
// set up our action list
actions = hot('a', { a: action });
// check that the observable output of the effect is what we expect it to be
expect(effects.getThings$).toBeObservable(
cold('a', { a: ThingActions.getThingsSuccess({ things }) })
);
});
⚠️ NB - It doesn't actually matter whether we use
hot
orcold
here. If more than one thing was going to subscribe to the effect it might actually make a difference, but that doesn't happen here. The difference betweenhot
andcold
observables can be extremely confusing and it may make no sense that within these tests it seems irrelevant, but sorting out that particular Gordian knot is beyond the scope of this article. Just know that this code, for the moment at least, does the job. (I don't want to encourage cargo cult development though, so when you have bandwidth to dig into the difference, there's a good article by Ben Lesh that goes into detail)
2.2 Unsuccessful service call
it('should emit an error action when the service call is unsuccessful', () => {
// set up the initial action that triggers the effect
const action = ThingActions.getThings();
const error = 'There was an error';
// spy on the service call and return an error this time
spyOn(service, 'getThings').and.returnValue(throwError(error));
// set up our action list
actions = hot('a', { a: action });
// check that the output of the effect is what we expect it to be
expect(effects.getThings$).toBeObservable(
cold('a', { a: ThingActions.getThingsFailure({ error }) })
);
});
This is pretty similar to the previous test except we've sneaked in usage of the throwError function. You can follow the link for more detail but all it does is create an observable that immediately emits an error notification, which is exactly what we want to mock as a return value from our getThings
method.
3. Multi-Dispatch Effect
initialiseThing$ = createEffect(() =>
this.actions$.pipe(
ofType(ThingActions.initialisingAction),
switchMap((_action) => this.thingService.getThings()),
switchMap((things) => {
const actions: Action[] = [];
if (!!things) {
actions.push(ThingActions.getThingsSuccess({ things }));
}
actions.push(ThingActions.initialiseComplete());
return actions;
})
)
);
Sometimes you need to dispatch more than one action. Again the choice of switchMap
or concatMap
(or even mergeMap
) is very much context dependent, the important thing here is that one action goes in and one or more come out.
⚠️ NB - This also illustrates a slightly magical feature of
switchMap
where if you return any array-like object from the projection, it will automagically treat it like an observable of the objects in the array. In this case it's like switching to anObservable<Action>
. If you find this confusing (and I wouldn't blame you here) you can replacereturn actions;
withreturn from(actions);
instead (cf. from).
3.1 Testing for multiple action output
it('should emit initialiseComplete & getThingsSuccess if thing is found.', () => {
const things = [
{
id: '1',
name: 'Thing 1',
},
];
jest.spyOn(service, 'getThings').mockReturnValue(of(things));
actions = hot('a', { a: ThingActions.initialisingAction() });
const expected = cold('(bc)', {
b: ThingActions.getThingsSuccess({ things }),
c: ThingActions.initialiseComplete(),
});
expect(effects.initialiseThing$).toBeObservable(expected);
});
This shows the usage of a sync grouping. That is, groups of notifications which are all emitted together. In this case, our getThingsSuccess
and initialiseComplete
. I've used this kind of pattern before to end an initialisation sequence of actions without making the last action do double-duty. Being able to fork your actions like this can be extremely useful if you have main sequences of actions with optional side quests being triggered (that's how I think of them).
3.2 Testing single action output
it('should just emit initialiseComplete if no things are found.', () => {
const things = [];
jest.spyOn(service, 'getThings').mockReturnValue(of(things));
actions = hot('a', { a: ThingActions.initialisingAction() });
const expected = cold('a', { a: ThingActions.initialiseComplete() });
expect(effects.initialiseThing$).toBeObservable(expected);
});
This should look familiar. There's nothing new introduced here at all! Yay!
4. Store Dependent Effect
storeReadingEffect$ = createEffect(
() =>
this.actions$.pipe(
ofType(ThingActions.thingsModified),
withLatestFrom(this.store.pipe(select(selectThings))),
map(([_action, things]) => this.thingService.persistThings(things))
),
{ dispatch: false }
);
Sometimes you end up needing to pull a value from the store. Don't feel bad about that. It's actually extremely common! In this case we're using withLatestFrom which means that every time we get a thingsModified
action, we grab the latest state and selectThings
from it. To test this, we need to provide some state and that's where provideMockStore
and the MockStore come into play.
it('should read things from the store and do something with them', () => {
const things = [
{
id: '1',
name: 'Thing 1',
},
];
// Note here that we have to provide a ThingsPartialState
// not just a ThingsState.
store.setState({
[fromThings.THINGS_FEATURE_KEY]: {
log: [],
things,
},
});
jest.spyOn(service, 'persistThings').mockReturnValue(of(things));
actions = hot('a', { a: ThingActions.thingsModified() });
expect(effects.storeReadingEffect$).toBeObservable(cold('a', { a: things }));
expect(service.persistThings).toHaveBeenCalledWith(things);
});
The only new thing here is that we call store.setState
. This is a wonderous boon to the test writing developer. In the old times we would actually dispatch actions to build up store state, but that would require those actions and associated reducers already existed and you would end up tightly coupling your tests to unrelated code. This is much simpler and neater (and it means you can write tests when the actions and reducers that might populate that slice of the store don't even exist yet).
5. Timed Dispatch Effect
timedDispatchEffect$ = createEffect(() =>
this.actions$.pipe(
ofType(ThingActions.startThingTimer),
delay(ThingsEffects.timerDuration),
mapTo(ThingActions.thingTimerComplete())
)
);
This is a slightly contrived example, but I have done similar things in the past. One particular case involved waiting for a few seconds so that a user could read a notification before they were redirected elsewhere.
To test this we need to abandon marbles though!
it('should dispatch after a delay (fakeAsync)', fakeAsync(() => {
actions = of(ThingActions.startThingTimer());
let output;
effects.timedDispatchEffect$.subscribe((action) => {
output = action;
});
expect(output).toBeUndefined();
tick(ThingsEffects.timerDuration);
expect(output).toEqual(ThingActions.thingTimerComplete());
}));
Angular handily provides us with the fakeAsync function which lets us control the flow of time. delay has its concept of time based on the scheduler it uses, so in order to test this with marbles we would have to (somehow) tell it that we want to use the TestScheduler alongside hot and cold rather than the default async Scheduler. This wouldn't be a trivial thing to do as often these kinds of operators are buried deep in your effect and you really don't want to have to start injecting schedulers into your effects. It's simpler just to discard marbles entirely and test it with fakeAsync
.
With fakeAsync
we set up a normal subscription to the effect as we would in non-test code and then trigger it by ticking forward time with a function appropriately called tick
. When we tick far enough, the observer will be triggered, output
will be populated and we can check that it matches what we expect!
That brings us to the end of these patterns with the important point that there is always another way to test them. You don't have to use marbles at all, in fact it could be argued that they make things more complicated for these kinds of cases not less! That decision is up to you. Don't worry too much about what you decide as long as it makes sense to you. There's never any point in sticking with something you find confusing. Do what works for you.
As always, if you have any questions, corrections or comments, feel free to get in touch here or on Twitter.
Posted on May 8, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.