RxJs testing patterns

Picture of china.
Photo by Pixabay from Pexels

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.

Understanding complex RxJs code may come as a challenge
Understanding complex RxJs code may come as a challenge

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.

Not all cases can be tested with done function
Not all cases can be tested with done function

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.

Jest test case raport
Jest test case report

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.

Jest real timers make tests slow to run
Jest real timers make tests slow to run

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.

Jest offers the functionality of moving forward in time
Jest offers the functionality of moving forward in time

The test runner doesn't rely on real-time, it mocks the passage of time which makes it blazing fast.

Test report when fake timers are used
Test report when fake timers are used

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.

Luke, Software engineer, passionate about best practices, frameworks React, Vue, patterns and architectures for modern web
				development, open-source creator, TypeScript enthusiast, Generic UI Technical Blog

Luke

Software engineer, passionate about best practices, frameworks React & Vue, patterns and architectures for modern web development, open-source creator, TypeScript enthusiast.