{"community_link":"https://community.indepth.dev/t/effective-rxjs-marble-testing/743"}

Effective RxJS Marble Testing

In this article we will focus on three points you should be aware of to use RxJS marble tests effectively.

Effective RxJS Marble Testing

TestScheduler is a powerful tool to test RxJS pipelines and operators. As you know, it makes tests declarative, simple, fast, and correct. In this article we will focus on three points you should be aware of to use RxJS marble tests effectively:

  • Can we use mutable data structure?
  • How to test pipeline based on Promise?
  • How to test meta and inner observables?

This is neither an introduction to RxJS nor to marble testing. Before reading the following sections you should be familiar with the basics of RxJS TestScheduler API. A good guide to marble testing is How to test Observables.

The source code is on Stackblitz.

Let’s start from the first point.

Mutability

The best practice is to use immutable data structures and pure functions in RxJS pipelines. This keeps the code coherent with the Functional Programming mindset of the library and allows us to avoid subtle aliasing bugs. But this is not enforced by RxJS. So, we can use mutable objects.

Let’s take a look at the example:

const toggle = 
  () => pipe(
    scan((acc, value) => {
        const found = acc.indexOf(value) > -1;
        if (found) {
          acc.splice(acc.indexOf(value), 1);    
        } else {
          acc.push(value);
        }

        return acc;
    }, []) 
  );
mutable toggle operator

The toggle operator checks if a received number already exists in the array accumulator. If it's already there, it will be removed, otherwise it will be added to the array. So, if we test the operator with the following code:

of(1, 2, 3, 2, 3, 1).pipe(
  toggle()
)
.subscribe(x => console.log(x));

The result is the following:

[1]
[1, 2]
[1, 2, 3]
[1, 3]
[1]
[]

So, the toggle is doing the right job and the printed result is correct.

Now, if we write the following marble test, it will fail:

testScheduler.run(({ expectObservable }) => {
    const source =  of(1, 2, 3, 2, 3, 1);

    const result = source.pipe(toggle());

    expectObservable(result).toBe(
    	'(abcdef|)',
        {a:[1], b:[1,2], c:[1,2,3], d:[1,3], e:[1], f:[]}
    );
});

The test fails because the TestScheduler does not take a snapshot of the object’s state. Instead, it just keeps a reference to the received object (here is the source code). So, if we mutate the object stored by RxJS, the test will be incorrect even if it passes.

To fix the code and its test, we convert the scan callback to a pure function:

const toggle = 
  () => pipe(
    scan((acc, value) => {
        const found = acc.indexOf(value) > -1;
        if (found) {
          return acc.filter(v => v !== value);    
        } else {
          return [...acc, value];
        }
    }, []) 
  );

Now that the marble test is passing, let’s jump to a real limitation.

Promise

RxJS has a good integration with Promises. An observable can easily be converted to a Promise and vice versa. However, taking advantage of this relationship prevents us from using the power of marble tests.

Let’s explain this point using a fictitious example. The following RedisCache is a third-party API that we use to store data into a remote Redis memory cache. The RedisCache is Promise-based API client.

interface RedisCache {
  get(...keys: string[]): Promise<Record<string, string>>;
}

When possible, we want to use only Observables in our source code. Therefore, we should not use this API directly in our codebase.

class RemoteCache {

  constructor(private redis: RedisCache) {    
  }

  get(key: string): Observable<{key: string; value: string;}> {
    return from(this.redis.get(key))
      .pipe(
        map(value => ({key: key, value: value[key]}))
      );
  }
}

To test the remote cache class we can create a stub as following:

class RedisCacheStub implements RedisCache {
  get(...keys: string[]): Promise<Record<string, string>> {
    return Promise.resolve({key: 'value'});
  }
}

But if we do this, we can’t use marble to test our class. Check this issue for more details.

testScheduler.run(({ expectObservable }) => {
    const cache = new RemoteCache(new RedisCacheStub());

    const result = cache.get('key');

    expectObservable(result).toBe('(a|)', {a:[1]});
});

One hack is to stub promises using observables.

class RedisCacheObservableStub implements RedisCache {
  get(...keys: string[]): Promise<Record<string, string>> {
    return of({key: 'value'}) as unknown as Promise<Record<string, string>>;
  }
}

When we cast Observable to Promise, the Typescript compiler will not complain. Moreover, RxJS will detect that the object is an Observable and not a Promise at the runtime,

The from factory function can create observable from different object types. Here the description of the input parameter from the official documentation:

A subscription object, a Promise, an Observable-like, an Array, an iterable, or an array-like object to be converted.

So, we can for example use an Array and statically cast it to Promise instead of Observable.

class RedisCacheArrayStub implements RedisCache {
  get(...keys: string[]): Promise<Record<string, string>> {
    return [{key: 'value'}] as unknown as Promise<Record<string, string>>;
  }
}

Let’s now jump to our last point in this post.

Meta and Inner Observable

With RxJS we can have meta-observable. One of the built-in operator that emit observable as a next value is groupBy. Using marbles we can easily test this kind of value. Here an example inspired by this documentation.

testScheduler.run(({ expectObservable, hot, cold }) => {
    const sourceValues = {
      a: {type: 'language', name: 'JavaScript'},
      b: {type: 'bundler', name: 'Parcel'},
      c: {type: 'bundler', name: 'webpack'},
      d: {type: 'language', name: 'TypeScript'},
      e: {type: 'linter', name: 'TSLint'}
    };
    const source = hot('--a---b---c---d---e---|', sourceValues);
    
    const result = source.pipe(groupBy(({type}) => type));

    const x = cold('      a-----------d-------|', languages()),
          y = cold('          b---c-----------|', bundlers()),
          z = cold('                      e---|', linters());
    expectObservable(result)
                 .toBe('--x---y-----------z---|', { x, y, z });
});

function languages() { 
  return {
    a: {type: 'language', name: 'JavaScript'},
    d: {type: 'language', name: 'TypeScript'}
  };
}

function bundlers() { 
  return {
    b: {type: 'bundler', name: 'Parcel'},
    c: {type: 'bundler', name: 'webpack'}
  };
}

function linters() { 
  return {
    e: {type: 'linter', name: 'TSLint'}
  };
}

In addition to the result observable, the x, y, and z are also defined as observable using the marble notation.

Beside meta-observable, we can also have inner observable. However, this type of observable is more tricky to test. We can’t use the marbles elegant notation.

testScheduler.run(({ expectObservable: expect, hot, cold }) => {
    const values = {
      a: {
         'languages': cold('--a--b', {a: 'JavaScript', b: 'TypeScript'}),
         'bundlers': cold('--a--b', {a: 'Parcel', b: 'Webpack'}),
         'linters': cold('--a', {a: 'TSLint'})
      }
    };
    
    const source = hot('--a--', values);

    source.subscribe(emited => {
      expect(emited.languages).toBe('----a--b', {a: 'JavaScript', b: 'TypeScript'});
      expect(emited.bundlers).toBe('----a--b', {a: 'Parcel', b: 'Webpack'});
      expect(emited.linters).toBe('----a---', {a: 'TSLint'});
    });
});

This is the simplest way to test inner observables. But, it’s not a good one. We should verify that there is a single issued value in the source observable.

Conclusion

RxJS marbles API are powerful tools that should exist in the toolbox of every RxJS developer. Being aware of their power, limitations, and subtleties make our use of these wonderful tools more effective.