Testing NgRx effects with promises – marbles alternative

You can test effects using marbles but unfortunately, they are very complicated. Instead of marbles, you can test your effects using promises without any trade-offs.

You can view the source code for this project by following this link: GitHub

Writing an example effect to test

Imagine you have written an effect to load tasks but only when they are not loaded yet.

1
const loadTasks = createEffect(
2
() => {
3
const actions$ = inject(Actions);
4
const toDoApi = inject(TaskApi);
5
const store = inject(Store);
6
7
return actions$.pipe(
8
ofType(ToDoActions.loadTasks),
9
switchMap(() => store.select(selectTaskState).pipe(take(1))),
10
switchMap((state) =>
11
state.status === "LOADED"
12
? EMPTY
13
: toDoApi.getTasks().pipe(
14
map((toDos) => ToDoActions.tasksLoaded({ tasks: toDos })),
15
catchError(() => EMPTY),
16
),
17
),
18
);
19
},
20
{ functional: true },
21
);
22
23
export const TasksEffects = { loadTasks };

Testing scenarios

Let’s now write testing scenarios. There should be three tests:

Writing a helper function for test cases

Each scenario will have the same configuration logic so I have created a function to not repeat it each time.

1
function createContext() {
2
const actions$ = new Subject();
3
const mockTaskApi = jasmine.createSpyObj<TaskApi>(TaskApi.name, ["getTasks"]);
4
5
mockTaskApi.getTasks.and.returnValue(timer(100).pipe(map(() => [])));
6
7
const injector = Injector.create({
8
providers: [
9
provideMockStore({ initialState: { task: initialState } }),
10
provideMockActions(() => actions$),
11
{ provide: TaskApi, useValue: mockTaskApi },
12
],
13
});
14
15
return {
16
injector,
17
actions$,
18
mockToDoApi: mockTaskApi,
19
mockStore: injector.get<MockStore<{ task: TaskState }>>(MockStore),
20
};
21
}

You can do the same with beforeEach function available in Jasmine.

Writing the test to check if getTasks has been called

To check if the method getTasks is called we have to do three things

  1. listen for the result of the effect by using firstValueFrom
  2. emit action to load tasks
  3. check what effect has returned
1
it('should call "getTasks" action when the status is INITIAL', async () => {
2
const { actions$, injector, mockToDoApi, mockStore } = createContext();
3
4
mockStore.setState({ task: { status: "INITIAL" } });
5
6
await runInInjectionContext(injector, async () => {
7
const effect = TasksEffects.loadTasks();
8
9
const resultPromise = firstValueFrom(effect);
10
actions$.next(ToDoActions.loadTasks);
11
12
expect(await resultPromise).toEqual(ToDoActions.tasksLoaded({ tasks: [] }));
13
expect(mockToDoApi.getTasks).toHaveBeenCalledTimes(1);
14
});
15
});

Writing the test to check if getTasks has not been called

The test will be similar to the previous one, but the difference is the initial state. Here the initial state will be with the status LOADED.

1
it('should not call "loadItems" action when the status is LOADED', async () => {
2
const { actions$, injector, mockToDoApi, mockStore } = createContext();
3
4
mockStore.setState({ task: { status: "LOADED", tasks: [] } });
5
6
await runInInjectionContext(injector, async () => {
7
const effect = TasksEffects.loadTasks();
8
9
const resultPromise = firstValueFrom(effect, { defaultValue: "END" });
10
actions$.next(ToDoActions.loadTasks);
11
actions$.complete();
12
13
expect(await resultPromise).toEqual("END");
14
expect(mockToDoApi.getTasks).toHaveBeenCalledTimes(0);
15
});
16
});

defaultValue will be returned when the subscription (the effect in this case) is completed without emitting any value. That’s so that we can await our promise. Without it, the promise will be rejected.

Writing the test to check if getTasks will not be called twice

In this test, we want to check the case when a user calls the action twice. We want to handle that case by ignoring the next action. The test should check if the method getTasks has been called only once even when the action was called twice.

1
it('should call "getTasks" only once ', async () => {
2
const { actions$, injector, mockToDoApi, mockStore } = createContext();
3
4
mockStore.setState({ task: { status: "INITIAL" } });
5
6
await runInInjectionContext(injector, async () => {
7
const effect = TasksEffects.loadTasks();
8
9
const resultPromise = firstValueFrom(effect);
10
actions$.next(ToDoActions.loadTasks);
11
actions$.next(ToDoActions.loadTasks);
12
13
expect(await resultPromise).toEqual(ToDoActions.tasksLoaded({ tasks: [] }));
14
expect(mockToDoApi.getTasks).toHaveBeenCalledTimes(1);
15
});
16
});

This test will fail because the effect does not have protection for this case. To handle this case we should replace switchMap with exhaustMap in effect implementation.

1
// switchMap((state) => state.status === 'LOADED' /** ... */)
2
exhaustMap((state) => state.status === "LOADED" /** ... */);

Do you like the content?

Your support helps me continue my work. Please consider making a donation.

Donations are accepted through PayPal or Stripe. You do not need a account to donate. All major credit cards are accepted.

Leave a comment