DEV Community

loading...
Cover image for Testing RxJS with Marbles

Testing RxJS with Marbles

Vince Blom
Full stack web developer, lover of Open Source, hacker of things.
・6 min read

Foreword

As we learned in our first installment of the Learning RxJS series, RxJS is a reactive programming library. RxJS makes use of Observables, defined in the RxJS documentation as:

A representation of any set of values over any amount of time. This is the most basic building block of RxJS.
  -source

So Observables are asynchronous, and represent a stream of values that are the result of an async operation. Anyone who has wrapped implementation code in an Angular project with a setTimeout() knows that testing that code in a fakeAsync() can cause some headaches, so you may be timid on putting the time into learning RxJS knowing you are adding more complex async code you'll have to test.

Why Marbles?

Marbles testing is the idea of breaking our Observables down into easy to read diagrams that show the passage of time for a specific Observable. They allow us to create rather easy to debug tests for complex, async, Observable based code. Lets look at the problem we are trying to solve.

Assume we have a simple piece of implementation code, a component that consumes some service that will be making an async call. Using the default HttpClient for Angular that call will return an Observable that we will need to consume in a component. That would look something like this:

export class MyService {

  constructor(private http: HttpClient) {}

  makeACall(): Observable<any> {
    return this.http.get('someUrl')
  }
}

export class MyComponent {
  value: any;

  constructor(private myService: MyService) {}

  getValue() {
    this.myService.makeACall().subscribe(val => this.value = val)
  }
}
Enter fullscreen mode Exit fullscreen mode

In this simple example our MyComponent is making a call into MyService, who makes an HTTP request. However that service returns the Observable of that call, so our component subscribes and stores that value off. Testing this extremely simple service code would look something like this:

describe('MyService', () => {
  it('should return a get request to someUrl', () => {
    let value = undefined;
    httpSpy.get.and.returnValue(of('catseye'))

    myService.makeACall().subscribe(val => value = val);

    expect(value).toEqual('catseye')
  })
})
Enter fullscreen mode Exit fullscreen mode

We can see that we are subscribing to the Observable returned by the service and storing that in a test scoped variable in order to test that value. We are loosely asserting that the value we push into the httpSpy is returned as an Observable from the service, and setting ourselves up for failure if this code were to grow more complex. We would need to do more and more work within the spec managing a fakeAsync timeline. Not to mention adding some common piped values to the HttpClient call such as a retry() or timeout() can easily make this code a pain to test and maintain.

Enter Marbles

A Marble Diagram is a simple string based diagram for representing the state of an Observable over time, they look something like this:

cold('-a--b-(c|)', { a: 'catseye', b: 'Bumblebee', c: 'Tiger' })
Enter fullscreen mode Exit fullscreen mode

Don't worry too much about the symbols used or what cold means, we will take a look at those in a minute.

Marbles essentially allows us to write the future of an Observable, which we can then return from a spy to be consumed by our implementation code. This is extremely powerful, especially so, when our implementation is going to be modifying/pipe()-ing that Observable and operating on it in some way; more on that in a minute. Lets take a look at how we construct a marble diagram.

Note: When referring to marbles diagrams this artice is directly referring to the jasmine-marbles library recommended by the RxJS team. The official documentation can be found here

Hot and Cold

There are two types of marbles that we can create, hot() and cold()

  • hot() marbles create a hot observable that immediately begins emitting values upon creation
  • cold() marbles create a cold observable that only start emitting once they are consumed

Most of the time you will be creating cold() Observables within your tests.

Marbles Dictionary

  • - - The dash is used to represent one "frame" of time, generally 10ms passing. (this value may be different depending on the library being used and whether or not the marble is run within the testScheduler.run() callback)
  • # - The hash is used to represent an error being thrown by the Observable.
  • | - The pipe is used to represent the Observable completing.
  • () - The parentheses are used to represent events occuring on the same frame.
  • a - Any alphabetical letter represents an emitted value.
  • 100ms - A number followed by ms represents a passage of time.
  • whitespace - Any and all whitespace is ignored in a marble diagram, and can be used to help visually align multiple diagrams.

There are also some subcription specific characters we can make use of:

  • ^ - The caret represents a subscription start point.
  • ! - The bang represents a subscription end point.

Emitting Values

Now that we know how to create a marble, lets look at how we emit values in a marble. Assume we need to emit a value of 'catseye' and then emit a specific error of the string 'Oops!' in order to test some logic.

cold('-a-#', { a: 'catseye' }, 'Oops!')
Enter fullscreen mode Exit fullscreen mode

The first parameter is our diagram, here saying that after one frame of radio silence we emit some value a, then go quiet for another frame, finally on our fourth frame we throw an error.

The second parameter is an object containing our emitted values where the object's key is the character we used in the diagram, in our case a.

The third parameter is the value of the error, which we decided in our test case needed to be the string 'Oops!'. Lets look at another, more complex diagram example:

cold('-a--b 100ms (c|)', ...)
Enter fullscreen mode Exit fullscreen mode

We are emitting value a on frame 2, value b on frame 5, then waiting 100ms. Then in a single frame our marble will emit value c and complete.

Writing Marbles Tests

Lets look at the service example from above, with a slight modification:

  makeACall(): Observable<any> {
    return this.http.get('someUrl').pipe(
      timeout(5000),
      retry(2),
      catchError(err => of(undefined))
    )
  }
Enter fullscreen mode Exit fullscreen mode

Here we are making that same Get request as we were before, but we are telling the Observable to timeout if no result is recieved within 5 seconds, and retry that call twice, returning undefined if we still fail after retrying. This is a pretty common pattern for HttpRequests that can fail silently in an Angular application, and not that fun to test using the traditional subcribe() methodology shown above. Marbles are here to save the day!

describe('makeACall', () => {
  it('should return the value from someUrl', () => {
    httpSpy.get.and.returnValue(cold('-a', { a: 'catseye' }))

    const expected$ = cold('-e', { e: 'catseye' })

    expect(myService.makeACall()).toBeObservable(expected$)
  });

  it('should retry twice on error', () => {
    httpSpy.get.and.returnValues(
      cold('#'), 
      cold('#'), 
      cold('-a', { a: 'catseye' })
    )

    const expected$ = cold('---e', { e: 'catseye' })

    expect(myService.makeACall()).toBeObservable(expected$)
  })

  it('should have a timeout of 5 seconds and return undefined on error', () => {
    httpSpy.get.and.returnValue(cold('- 5000ms'))

    const expected$ = cold('- 15000ms e', { e: undefined })

    expect(myService.makeACall()).toBeObservable(expected$)
  })
})
Enter fullscreen mode Exit fullscreen mode

All we need to do to make sure the source and expected Observables are working on the same timeline, is to line up the diagrams in terms of frames and timed waits.

A Note on Developer Experience

As we can see in the above examples we are creating an easily recreatable testing pattern. In order to understand the case all we need to do is look at the string pattern within the "source" returned by the httpSpy.

Marbles has allowed us to test more complex logic using the same pattern in all of our tests. Establishing patterns in your tests allows other developers to more easily write tests for new implementation code (and help you when you come back to that service you wrote 6 months ago).

Summary

Marbles testing gives us a rich shared language for testing Observables and creating easy to extend testing patterns. We can also test more complex RxJS code without getting lost in the weeds of how to test it. Overall we are able to write better tests that are easier to understand, improving the Developer Experience and allowing us to move faster without sacrificing code quality.

If you have any questions about using marbles in real practice, marbles in general, or the wider world of RxJS drop them in the comments below.

Further Reading

Discussion (1)

Collapse
tonivj5 profile image
Toni Villena

Very well explained topic! 👏

Forem Open with the Forem app