There are many options out there when testing reactive code based on RxJS. Hence, especially for beginners, it can be hard to choose between all the RxJS testing approaches. This leads to frustration and in the worst case, the code remains untested. In this article, I want to show different patterns and their problems.
Please note, that the mentioned code is reduced to the minimum to bring the necessary points across. The full tests can be found on GitHub. Also, we are only scratching the surface of various testing approaches and mainly checking setup and verification.
After the introduction of an example code to be tested you will see:
- Mocking with the of operator and subscribing explicitly
- Using BehaviorSubject for mocking
- RxJS marbles for mocks and outcomes
- Auto-spies for setup and observer-spy to verify values
Example: combining asynchronous resources on an action
Sometimes you need to load something from an HTTP service and based on the result you retrieve something related to that. Finally, everything should be emitted together. The resources to load are based on values emitted by an Observable. And to make it even more reactive the trigger to load things is also an Observable emitting a value. This is going to be the scenario for our tests.
For our example, you can think of a “Load more” button on a feed that loads more posts and their associated comments. An action is triggered to load posts followed by their comments. The action itself tells us which posts to load. So, the code may look like the following:
loadPostAction$.pipe(
map(action => action.postIds),
concatMap((postIds: number[]) =>
from(postIds).pipe(
mergeMap(postId => postService.loadPost(postId)),
),
),
mergeMap(post => commentService.loadComments(post.id).pipe(
map(comments => ({ ...post, comments: comments }))
)),
);
ⓘ Some explanations:
loadPostAction$
is an Observable emitting an action that contains the IDs of those posts that we want to load.loadPost
of thepostService
is a method to call an HTTP service that returns a single post. It accepts a post ID as input.- The
commentService
offers a methodloadComments
. It accepts a post ID and returns an array of comments as Observable for that post.
For testing, we set up some basic mocks. The content of the post is going to contain “content of post” followed by the post ID. For the comments, we want to return an array of two comments mentioning a static text alongside the post ID. If we run the above code in the test context, we expect something like the below output:
{
id: 5,
content: 'content of post 5',
comments: [ 'first comment (5)', 'another comment (5)' ]
}
{
id: 6,
content: 'content of post 6',
comments: [ 'first comment (6)', 'another comment (6)' ]
}
{
id: 7,
content: 'content of post 7',
comments: [ 'first comment (7)', 'another comment (7)' ]
}
For each post, the comments are loaded and assigned to the Post
object. There are better ways to implement the above functionality. And one would maybe structure the code differently. However, it is sufficient as an example for us. We are ready to go and explore the first option for testing.
1. Mocking with the of operator and subscribing explicitly
Our code is based on Observables. Wouldn’t it be great, to just use the RxJS of operator to set those Observable values? Let’s try.
We define the loadPostAction$
, loadPost
and loadComments
as follows:
const loadPostAction$: Observable<LoadPostsAction> = of({ postIds: [5,6,7] });
const loadPostMock = jest.spyOn(postService, 'loadPost');
const loadCommentsMock = jest.spyOn(commentService, 'loadComments');
loadPostMock.mockImplementation((postId: number): Observable<Post> => of(
{id: postId, content: `content of post ${postId}` }
));
loadCommentsMock.mockImplementation((postId: number): Observable<string[]> => of(
[
`first comment (${postId})`,
`another comment (${postId})`
]
));
The action is an Observable based on of, while the two service methods are mocked with the help of jest. In the end also they are based on the of operator. Now we can assign the loadPostAction$.pipe...
to a result$
Observable, explicitly subscribe, push the values into an output array, and verify:
// const result$ = loadPostAction$.pipe( ...
// map... etc.
const output: Post[] = [];
result$.subscribe({
next: post => output.push(post),
});
expect(output).toEqual(expectedPosts);
Testing with of
and subscribe
is more or less straightforward. However, it has the following disadvantages:
- ⏱ There is no notion of time to specify easily at what exact moment values are emitted. Similarly, it is harder to assert when values arrive in the final result Observable.
- ⏳ Sometimes time-based Observables, like interval, are used in the code under test. In this case, the run time of the test is most likely going to be longer than just a few milliseconds.
- ✍ Generally setting up subscribe and pushing results to an array (if necessary) can be tedious. If you want to test error and complete notifications, it is even more work.
- ❗Subscribing might also cause memory leaks in some cases.
On to the next section where the setup part is modified slightly.
2. Using BehaviorSubject for mocking
In Section 1 the Observables were defined using the of operator that emits synchronously:
const loadPostAction$: Observable<LoadPostsAction> = of({ postIds: [5,6,7] });
This is not very convenient when you want to see different values separated by time emitted during a single test. This is where BehaviorSubject comes into play. As you probably know, a Subject in RxJS is an Observable and an Observer at the same time. Therefore it offers a next method to push some values.
const loadPostAction$: BehaviorSubject<LoadPostsAction> = new BehaviorSubject({ postIds: [5,6,7] });
Thanks to the Subject, we can test how the code behaves after the loadPostAction is triggered with some new post IDs.
// still subscribing to result$ and pushing to an output array
// just pushing some new values
loadPostAction$.next({ postIds: [8,9,10] });
expect(output[3].id).toEqual(8);
expect(output[4].id).toEqual(9);
expect(output[5].id).toEqual(10);
After having loaded the posts with IDs 5, 6, and 7, we are interested in the three next posts (8, 9, and 10). To keep things simple, the assertion here is done on the id
field. This variant with BehaviorSubject works too, but it still feels a bit awkward:
- ✓ We can push different values for an involved Observable.
- ⏱ There is still no notion of time (which might be ok for simple tests).
- 📏 The verification phase wasn’t changed at all and still involves subscribing explicitly and checking the content of an array.
3. RxJS marbles for mocks and outcomes
RxJS offers marble testing. To apply it, one has to first understand the marble syntax. Through the helper methods, hot and cold Observables can be created that follow a certain timing.
const loadPostAction$: Observable<LoadPostsAction> = hot('-a-----b-------|', {
a: { postIds: [5,6,7] },
b: { postIds: [8,9,10] },
});
const loadPostMock = jest.spyOn(postService, 'loadPost');
const loadCommentsMock = jest.spyOn(commentService, 'loadComments');
loadPostMock.mockImplementation((postId: number): Observable<Post> => cold('-r|', {
r: {id: postId, content: `content of post ${postId}`}
}));
loadCommentsMock.mockImplementation((postId: number): Observable<string[]> => cold('--r|', {
r: [
`first comment (${postId})`,
`another comment (${postId})`
]
}));
ⓘ Some notes to the above setup:
loadPostAction$
was defined as a hot Observable. Already there we are defining the emissions over time (IDs 8, 9, 10 coming 6 time frames after the first batch of IDs).- For the service’s methods, we again spy on the service classes and mock the implementation. Here the return values are based on cold Observables involving one or two frames of waiting time respectively.
Also for the verification step, marbles offer us a convenient method expectObservable. It lets us define the expected output also as a marble diagram, i.e. as a string with time frames and emitted values.
expectObservable(result$).toBe('----(abc)-(def)|', {
a: {
id: 5,
content: 'content of post 5',
comments: [ 'first comment (5)', 'another comment (5)' ]
},
...
On frame 4 the first post with ID 5 arrives in the output together with two other posts (ID 6 and 7). On frame 10 the next group of 3 are emitted. Please note that parentheses also move the time forward. Finally, the | sign denotes completion. This is really cool if we want to test the exact timing. For our example though it wouldn’t matter that much. We are just interested in receiving posts.
Let us pin down the most important points for marble testing
- ⏱ Marbles testing is a good choice if the timing of the Observables is important.
- 😃 Setup and verification can both be done in a similar way.
- ✓ It not only allows testing the next notification but also testing errors and especially completion is rather easy.
- ✍ Sometimes it can be tedious to find the exact time span for the result verification. You find yourself adjusting frame by frame.
- 😤 Especially the time progression due to the parentheses is annoying and can prevent testing certain scenarios.
- 🙄 The setup in the tests can be a bit tedious, especially when using the plain test scheduler. There are libraries though for jest and jasmine.
4. Auto-spies for setup and observer-spy to verify values
For some devs, the marbles syntax is hard to get or the timing of emissions is not important for the code under test. In that case, observer-spy might be a better choice for testing RxJS. It usually is combined with auto-spies to set up the input Observables.
let loadPostActionSpy$ = createObservableWithValues(
[
{ value: { postIds: [5,6,7] } },
{ value: { postIds: [8,9,10] }, delay: 1000 },
{ complete: true },
],
{ returnSubject: true }
);
const loadPostAction$ = loadPostActionSpy$.values$;
const postServiceSpy = createSpyFromClass(PostService);
const commentServiceSpy = createSpyFromClass(CommentService);
postServiceSpy.loadPost.mockImplementation((postId: number): Observable<Post> => of(
{id: postId, content: `content of post ${postId}` }
));
commentServiceSpy.loadComments.mockImplementation((postId: number): Observable<string[]> => of(
[
`first comment (${postId})`,
`another comment (${postId})`
]
));
ⓘ Let us see what is going on in the above code:
loadPostAction$
is defined with the help of the methodcreateObservableWithValues
. This lets us define various emissions and the completion (without marble diagrams 😉)- The
createSpyFromClass
method of jest-auto-spies is used to mock the implementation.
The verification part of the test looks pretty neat as you are going to see below. By using subscribeSpyTo
we can create an observerSpy
that lets us continuously check the values. Various methods are available (see docs) to test, e.g., all values, just the last ones, or the completion.
const observerSpy = subscribeSpyTo(result$);
// some intermediate verification for posts 5,6,7
expect(observerSpy.getValues()).toEqual(expectedPosts);
await observerSpy.onComplete();
expect(observerSpy.getLastValue()?.id).toEqual(10);
expect(observerSpy.receivedComplete()).toBe(true);
Let us also list the most important characteristics of this RxJS testing approach:
- ✍ Auto-spies combined with observer-spy offer similar capabilities to rxjs-marbles without the need to define the marble diagrams.
- ⏱ Some timing can be defined (see createObservableWithValues).
- ✅ Verification does not involve any nesting or additional data structures.
- 😊 Checking values feels reasonable. We can access and check the parts we are interested in.
Summary
As so often in web development, we have many different approaches available. Choosing the right one from the RxJS testing approaches depends on many factors. Is the timing of the Observables important for the code under test? Do we only care about the values and the order in which they appear in the output? Especially in larger code bases it is beneficial to choose one approach and stick with it. Otherwise, the code base is not consistent which further confuses developers.
This post is part of the RxJS mastery series. As always you can find the code examples on GitHub.