DEV Community

Jo Hanna Pearce
Jo Hanna Pearce

Posted on

How to Test Five Common NgRx Effect Patterns

⚠️ 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 NgRx ofType 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 or cold 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 between hot and cold 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 an Observable<Action>. If you find this confusing (and I wouldn't blame you here) you can replace return actions; with return 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.

Top comments (10)

Collapse
 
danielsc profile image
Daniel Schreiber

The last one can as well be done with marbles:

    it('should dispatch after a delay (getTestScheduler)', () => {
      getTestScheduler().run(helpers => {
        actions = helpers.hot('a', {a: ThingActions.startThingTimer()});
        helpers.expectObservable(effects.timedDispatchEffect$).toBe('1000ms a', {
          a: ThingActions.thingTimerComplete()
        });
      });
    });
Enter fullscreen mode Exit fullscreen mode
Collapse
 
zoechi profile image
Günter Zöchbauer
Collapse
 
lmfinney profile image
Lance Finney

Two thoughts:

  1. I find it interesting that you seem to default to marbles, but switch to direct subscription only when necessary. I personally go in the opposite direction. Do you think there's a solid reason for your approach, or is it more personal preference?
  2. One of the situations where I abandon subscription for marbles is when I have a filter in my effect. I haven't figured out a satisfying way to test an Observable that doesn't ever emit anything, except through a marble test that asserts a cold('') result. If you build upon this article in the future (or if Brandon snaps it up for NgRx documentation), that might be a good 6th pattern to add.
Collapse
 
jdpearce profile image
Jo Hanna Pearce
  1. It's just something I'm used to - I think marbles tests maybe look a little neater? I did consider rewriting this to ignore marbles entirely though.

  2. For filters and testing with subscriptions I'd just do what I did in the last pattern and then ensure that output is still undefined. Testing for a negative is always going to be a bit flaky though, imo.

Collapse
 
lmfinney profile image
Lance Finney

I think there's a bug in an early example. I suspect you intended const x = y ? 1 : 0; to be const x === y ? 1 : 0;

Collapse
 
jdpearce profile image
Jo Hanna Pearce

No, y is the value I'm checking the value of, I'm then assigning 1 or 0 to x based on whether it's true or false respectively.

Collapse
 
lmfinney profile image
Lance Finney

Gotcha. I totally misread it. Sorry.

Thread Thread
 
isaacplmann profile image
Isaac Mann

This is part of why I don't like ternaries. Even if the person writing the ternary knows exactly what they're doing, someone else reading the ternary isn't sure that the writer actually meant what they wrote.

Thread Thread
 
jdpearce profile image
Jo Hanna Pearce

I think I'm just so used to them these days that it never occurs to me that they're unclear to anyone but junior devs. I like the brevity.

Having said that, I always read them with a subvocalised question intonation, so it always sounds like a "Really? Yes : No" in my head, which is maybe what has helped the pattern seem "obvious" to me.

Collapse
 
catazep profile image
Georgescu Catalin • Edited

Hey, a little bit of help, plz :D
I've been staying on this for about 3 hours and I just don't get it...
dev-to-uploads.s3.amazonaws.com/up...