How (not) to Unit-Test Your RxJS Code

How (not) to Unit-Test Your RxJS Code

Published:

Do you want to test your RxJS code? This is a great idea! But what kind of tests are best suited to test your RxJS pipelines? There are the traditional asynchronous tests and marble tests, coming with RxJS. Let’s have a discussion.

Cover image

Note: This article assumes that you have some experience with RxJS or at least have read my article on How to learn RxJS FAST! in 2023 . It will also help, if you are familiar with JavaScript testing libraries.

Which unit test approaches are there?

In the RxJS documentation, you can find the so-called Marble Tests . These tests are provided via a TestScheduler that enables you to run asynchronous RxJS code synchronously.

Another approach would be to run your tests asynchronously with Promises (or async / await).

Before elaborating which tests make more sense, let’s take a closer look at those two types of tests.

Asynchronous tests with Promises

First, it’s worth mentioning, that by “asynchronous” tests, we don’t necessarily have to really wait for timers and intervals. In most testing libraries, you can enable fake timers that allow you to emulate the progression of time. So, the code runs synchronously, but we still have to await Promises because their callbacks are placed on the event loop.

As you should know by now, RxJS Observables are more powerful than Promises. They can emit multiple values and they are lazy.

But you can still test your code with Promises: Every test has a certain start condition, at least one expectation. Your RxJS code has to run for a certain (virtual) time, until the expectation can be validated.

We also have some operators that can help us wait for exactly this point in time that we are waiting for.

You could, for example, wait until a certain count of values was emitted by your Observable by using the take() Operator. You can also turn Observables into Promises with the functions firstValueFrom() and lastValueFrom() . With the latter one, you can also wait for the completion of an Observable (if it emits a value).

const countTicks$ = timer(1000, 1000).pipe(
  scan((c) => c + 1, 0),
);

vi.useFakeTimers();

it("should emit consecutive values within a time frame", async () => {
  const emissions = firstValueFrom(
    countTicks$.pipe(bufferCount(3)),
  );

  vi.advanceTimersByTime(3000);

  expect(await emissions).toEqual([1, 2, 3]);
});

The Observable, we want to test, counts the ticks emitted by a timer.

As mentioned above, we don’t really have to wait for timers and intervals. We achieve this by using fake timers.

We can easily test this observable by buffering some emissions and taking the first buffered emission. firstValueFrom() will subscribe to the observable.

So, if we proceed in time, it should emit values.

We do this by advancing virtual time by the timeframe by which the emissions should be complete.

Finally, we can check whether the promise is completed and holds the correct captured the values.

Synchronous tests with Marble Tests

Using the TestScheduler from RxJS, you can create source observables from the hot() and cold() helper functions. They consume a marble string which describes when which value shall be emitted. Here is the example from above rewritten in marble test approach:

function countTicks(clock$: Observable<number>) {
  return clock$.pipe(scan((c) => c + 1, 0));
}

const testScheduler = new TestScheduler(
  (actual, expected) => {
    expect(actual).toEqual(expected);
  },
);

it("should emit consecutive values within a time frame", () => {
  testScheduler.run(
    ({ expectObservable, cold }) => {
      const clock$ = cold<number>(
        "1s a 999ms b 999ms (c|)",
        { a: 0, b: 1, c: 2 },
      );
      const countTicks$ = countTicks(clock$);

      expectObservable(countTicks$).toBe(
        "1s a 999ms b 999ms (c|)",
        { a: 1, b: 2, c: 3 },
      );
    },
  );
});

First, we set up our TestScheduler() instance. It is test framework independent, so we have to tell it how to compare results with the expectation.

We now have to write the countClicks$ as a function that takes a clock$ source Observable. We cannot use the timer() Operator, since it is asynchronously scheduled.

Our actual test code is placed inside a callback that we pass to testScheduler.run() . We get some helper functions provided as parameters to this callback.

We can use the cold() helper, to create a cold Observable. It takes in a string that describes when, which values shall be emitted. This string uses the marble syntax , we can find in the docs. The second parameter holds a map of the actual values.

Now it’s time to create our countTicks$ Observable and check whether it emits the values as expected.

What may be a bit confusing is the fact that we have to wait 999ms instead of 1s between the emissions. This is because the emissions are virtually “eating up” 1ms .

Which approach is better?

Some time ago, I gave marble tests a try. I liked the marble syntax, and it seemed to be a great tool to test my reactive code. But later on, I changed my mind. Here is why.

Coupling to the implementation details

When you write marble tests, you verify when exactly which emission happens. And often you even check when exactly which subscription and unsubscription takes place.

While such detailed assertions occasionally may be valuable, It comes at a cost. You will quickly find your testing code more tightly coupled to the implementation details of your unit under test than it actually needs to.

Having your testing code tightly coupled to the implementation makes it more likely that you have to adapt the tests if you do a simple refactoring.

Also, switching from an Observable API to Promises requires fewer changes in unit tests that are not based on marbles.

Limitations and issues

In the docs, there is also a known issues section . And the main issue is that you cannot test Promises with marble tests.

This can quickly become an issue, if you consume Promise-based APIs, which are nowadays omnipresent in JavaScript.

Complex testing code

Revisiting the test implementation, we will notice that the marble tests are more complex than the asynchronous tests. Of course, we can simplify it a bit by writing our own abstractions.

But from my experience, it will still stay more complex than the alternative solution. And keep in mind that your own abstractions are special for your project, and every new team member has to learn them.

Tests driving your code design

As we already saw in the marble test example above, we could not simply derive our Observable under test from the timer() Operator. Instead, we had to build a factory that takes in a source clock, from which we derive our Observable.

In other words, using marble tests changes how we would design the actual implementation code.

This can get quite extreme: I found myself changing some of our interfaces to expose Observables instead of Promises, and also wrapping native interfaces with Observable APIs. All in the mission to enable our code to be marble testable.

This was the time when we decided to throw away marble tests altogether and switch to basic asynchronous tests.

All in all, it was fun, playing around with marble tests and being able to exactly control the emission timing in detail. But in review, we could have used this time more effectively.

I hope this article helps you make a good decision for your RxJS testing strategy.