RxJs testing patterns
Testing the RxJs library may require programmers to change their way of thinking as it brings lots of challenges. It is agnostic from any JavaScript framework and has lots of features which makes it a very popular library amongst frontend technical stack. The reactive way of building applications requires developers to change the way they think about application runtime behavior. That said it may be challenging to understand the data flow and the internal behavior of the application. From my experience, one of the techniques which helps developers to figure out better what the code does is to write more unit tests. They not only improve the stability and maintainability of the code but also through the process of writing and reading they also increase the developer's understanding of the code.
In this article, I will show you some of the patterns and techniques which help me test reactive RxJs code. I use them on a daily basis and I think you will as well if you're not using them already. As a testing framework, I used one of the best solutions available: Jest.
Observable without any value
One of the common situations is when you want to test an observable which doesn't return any value. Like in the example below, values are filtered out by the filter
operator and no data is passed to the subscribe function:
of(1, 2, 3)
.pipe(
filter(v => v > 3)
)
.subscribe(console.log); // no value
How to check whether the value is not emitted from the observable? The most popular solution is to use the done
function provided by the Jest framework. Declaring the done
function simply tells the test runner that the test cannot be ended until this function is called. The runner waits for it to be called when the of
observable completes.
it('verifies that value is greater than 3', (done) => {
// when
of(1, 2, 3)
.pipe(
filter(v => v > 3)
)
.subscribe({
complete: () => done()
});
});
This solution works because we know the exact behavior of the of
creator. It emits all of the values at the beginning and then the observable completes. What about the cases when it won't complete? The real-life example of this is when you need to verify that Subject
or ReplaySubject
is empty at start:
it('checks if ReplaySubject is empty at start', (done) => {
// given
const subject = new ReplaySubject(1);
// when
subject
.subscribe({
complete: () => done()
});
});
In this case done
will never be called because the subscriber for the ReplaySubject
never completes.
Mock functions
Once again Jest offers the perfect solution for this type of test case, which is the Jest mock functions.
Mock functions allow you to test the links between code by erasing the actual implementation of a function, capturing calls to the function (and the parameters passed in those calls), capturing instances of constructor functions when instantiated with new, and allowing test-time configuration of return values.
We can simply use mocks to verify that the next function was never called:
it('checks if ReplaySubject is empty at start', () => {
// given
const nextFn = jest.fn();
// when
new ReplaySubject(1)
.subscribe({
next: value => nextFn(value)
});
// then
expect(nextFn).not.toHaveBeenCalled();
});
In the example above you can see that, the nextFn
function should be called when the ReplaySubject
emits a value. It never does so we can assert that the nextFn
has never been called. This approach can also be used to test observables and verify that they emit no values.
Let's get back to the first code example and let's see how it will look like with the mock functions:
it('verifies if value is greater than 3', () => {
// given
const nextFn = jest.fn();
// when
of(1, 2, 3)
.pipe(
filter(v => v > 3)
)
.subscribe({
next: value => nextFn(value)
});
// then
expect(nextFn).not.toHaveBeenCalled();
});
Test errors
Mock functions can be also used to assert errors thrown from observables:
it('should verify value greater than 3', () => {
// given
const nextFn = jest.fn(),
errorFn = jest.fn(),
completeFn = jest.fn(),
givenError = new Error('Test error');
// when
throwError(givenError)
.subscribe({
next: value => nextFn(value),
error: error => errorFn(error),
complete: () => completeFn()
});
// then
expect(errorFn).toHaveBeenCalledWith(givenError);
});
You can specify exactly which behavior is expected from the tested observable. With the use of mock functions, you can verify that observable has emitted a value, thrown an error, or has simply completed.
Long-running observables
So far we've seen patterns regarding synchronous observables but the most common real-life usages are revolving around observables that return value over time. The existence of time adds additional complexity to the test itself and makes it harder to compose. Let's have a look at this example code:
it('waits for timer', () => {
// given
const givenValue = 'Bruce Wayne';
// when
timer(3000)
.pipe(
mapTo(givenValue)
)
.subscribe({
next: value => {
// then
expect(value).toEqual(givenValue);
}
});
});
It uses timer
which creates an observable that emits a value after a period of time. The test case above runs synchronously, the code is invoked line by line. The runner doesn't wait for the callback inside the subscribe
function to have been called. The expect
assertion is never executed.
In order to test it, we need to add some sort of delay to the test. The common approach is to use the previously mentioned done
function, which will wait for an exact period of time until the test calls it from within.
it('waits for done to end', (done) => {
// given
const givenValue = 'Bruce Wayne';
// when
timer(3000)
.pipe(
mapTo(givenValue)
)
.subscribe({
next: value => {
// then
expect(value).toEqual(givenValue);
},
complete: () => done()
});
});
As you can see the timer
emits a value after a specific period of time and then completes. In this test the done
function is executed on an observable completion, so the test case runner needs to wait. The only problem with this method of testing asynchronous code is time. It takes astounding 3 seconds to run this test case.
Imagine an enterprise-scale project in which you may have a thousand such test cases. Working in that kind of environment would be a real nuisance.
Fake timers
Again Jest comes with a helpful hand and brings us the fake timers which come as the perfect solution. The replace the native timer functions i.e. setTimeout
, setInterval
, clearTimeout
, clearInterval
with a fake implementation, which doesn't rely on real-time. This is what makes test cases run really fast.
it('works fast with fake timers', () => {
jest.useFakeTimers();
// given
const givenValue = 'Bruce Wayne',
nextFn = jest.fn();
// when
timer(3000)
.pipe(
mapTo(givenValue)
)
.subscribe({
next: value => nextFn(value)
});
jest.advanceTimersByTime(3000);
// then
expect(nextFn).toHaveBeenCalledWith(givenValue);
});
In the beginning, we declare that we will use fake timers with jest.useFakeTimers()
and then after the subscription we move to the future with the function jest.advanceTimersByTime
so we don't have to wait. Code acts as it was executed synchronously.
The test runner doesn't rely on real-time, it mocks the passage of time which makes it blazing fast.
The test above is just a simple example of fake timers. Jest offers much more functions e.g. runAllTimers
, runOnlyPendingTimers
, runAllTicks
. It is a very powerful feature I highly recommend reading more about it in the official documentation.
Verifying a series of values
One of the most known traits of observables is that they return a series of values. On the other hand, this helpful feature may come as a challenge when it comes to testing. I guess the most popular approach is to just store emitted values in an array and then make an assertion against that array. In my opinion, it comes as an additional boilerplate which makes code less readable:
// given
const values = [],
source$ = of(1, 2, 3)
.pipe(
map(v => v * 3)
);
// when
source$.subscribe(v => values.push(v));
// then
expect(values).toEqual([3, 6, 9]);
The RxJs library provides us with an operator called toArray
which collects all the emitted values and returns them when the observable completes. So it is a perfect fit for our case:
it('tests a series of values with the toArray operator', () => {
// given
const values = [],
source$ = of(1, 2, 3)
.pipe(
map(v => v * 3)
);
// when
source$
.pipe(
toArray()
)
.subscribe(values => {
// then
expect(values).toEqual([3, 6, 9]);
});
});
Mock function with a series of values
The other approach to that issue is to verify that the jest mock function has been called many times. In the example below, you can see that there are three assertions for the nextFn
function which verifies if it was called with the expected values.
it('tests a series of values with a mock function', () => {
// given
const nextFn = jest.fn();
// when
of(1, 2, 3)
.pipe(
map(value => value * 3)
)
.subscribe(value => nextFn(value));
// then
expect(nextFn).toHaveBeenCalledWith(3);
expect(nextFn).toHaveBeenCalledWith(6);
expect(nextFn).toHaveBeenCalledWith(9);
});
All of the code examples are located in this Github repository.
Summary
The RxJs is a great library that allows programmers to introduce reactivity into their web applications and testing it brings lots of challenges. Fortunately, we have a powerful set of tools like mock functions, fake timers, RxJs operators, Jest matchers. It helps us deal with all of the difficulties that testing asynchronous code brings.