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)
}
}
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')
})
})
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' })
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 thetestScheduler.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 byms
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!')
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|)', ...)
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))
)
}
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$)
})
})
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
-
The official RxJS docs on marbles testing
- These docs refer to using the
testScheduler.run()
callback, so the examples may look a bit different but are equally valid.
- These docs refer to using the
Top comments (1)
Very well explained topic! π