May 14th, 2021 - written by Kimserey with .
In Angular, we often use RxJS observables. Since observables are dependent on time, it makes them to hard to test. To make our life easier, RxJS provides a TestScheudler
which offers a function run
providing helper functions accepting Marbles, a special syntax used to define a timeline of events. In today’s post, we will learn about the Marble syntax and see how we can use it to test the behaviour of our observable composition.
For this post, we will demonstrate the usage of the test schduler for tests written with Mocha
and Chai
in Typescript. This would be our dependencies:
1
2
3
4
5
6
7
8
9
10
"dependencies": {
"rxjs": "^6.6.3"
},
"devDependencies": {
"@types/chai": "^4.2.12",
"@types/mocha": "^8.0.3",
"chai": "^4.2.0",
"mocha": "^8.1.3",
"typescript": "^4.0.2"
}
We can then use TestScheduler
from rxjs/testing
.
1
2
3
const testScheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equal(expected);
});
The argument expected from the constructor of TestScheduler
is a deep equality check. Here we use Chai
deep equality check but we could use whatever fit our current test suite.
The equality check function will be called by the test scheduler everytime we asset observables or subscriptions.
actual
and expected
are either observable or subscription which comeback in frames. Each frame
will contain the frame number and a notification. The notification will contain the value
, error
and kind
. Kind represents the kind of notification, 'N'
for next notification, 'E'
for error and 'C'
for completion (the types can be found in the rxjs types). The frame object roughly looks like that:
1
2
3
4
5
6
7
8
{
frame: number
notification: {
error: any
kind: 'N' | 'E' | 'C'
value: T
}
}
For debugging purposes we alos add a logging function which would log the frames:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function logFrames(label: string, frames: any) {
console.group(label);
frames.forEach((frame) => {
console.log(
"Frame:",
frame.frame,
"Kind",
frame.notification.kind,
"Value:",
frame.notification.value
);
});
console.groupEnd();
}
And add the call prior the check so that we can inspect the content of the frames:
1
2
3
4
5
const testScheduler = new TestScheduler((actual, expected) => {
logFrames("actual", actual);
logFrames("expected", expected);
expect(actual).deep.equal(expected);
});
We will see how the logs display in the next section. Now that we know how we assert on the observables, we can move on to the most difficult part which is how we setup observables.
Observables being stream of data, if we need to assert on them, we can’t validate only at a specific point in time, we need to validate at every point in time. The marble syntax allows us to define the sequence of values being streamed for an observable. We can then write the expected output marbles and assert on them.
1
2
3
4
5
6
7
testScheduler.run(({ cold, expectObservable }) => {
const obs = cold(" -a-b-c-|");
const expected = " -a-b-c-|";
expectObservable(obs).toBe(expected);
});
});
This will result in:
1
2
3
4
5
6
7
8
9
10
actual
Frame: 1 Kind N Value: a
Frame: 3 Kind N Value: b
Frame: 5 Kind N Value: c
Frame: 7 Kind C Value: undefined
expected
Frame: 1 Kind N Value: a
Frame: 3 Kind N Value: b
Frame: 5 Kind N Value: c
Frame: 7 Kind C Value: undefined
For this test we’ve deconstructed the helpers from run
function into cold
and expectObservable
. The helpers also exposes hot
, expectSubscriptions
.
cold
is used to create a cold observable,hot
can be used to create a hot observable,expectObservable
is used to trigger the assertion on observables,expectSubscriptions
is used to trigger the assertion on the subscriptions.A cold observable is an observable which gets created on subscription, while a hot observable is an observable which is indifferent from the subscription and continuously emits even without subscriptions.
This is important to represent what our application would behave and be able to test that observable.
For example:
1
2
3
const obs = cold(" -a-b-c-|");
const expected = " -a-b-c-|";
const expected2 = " ---a-b-c-|";
Here expected2
subscribes on frame 2 (frames are zero based), because obs
is a cold
observable, its whole content gets published. From this example we can already see the usage of -
, [a-z]
and |
.
(empty space)
are ignored, they are mainly used to align the marbles,-
represents a frame, a frame is a virtual time which by default correspond to 1ms,[a-z0-9]
can be used to represent alphanumeric single value, this is useful to simply test the behaviour of items being published to observables, but they can also be used to map to object or arrays, [a-z]
would map to properties of the object, [0-9]
would map to array index,[0-9]+[ms|s|m]
represents a time progression for example 9ms
,#
represents an error,()
represents a group of values to be emitted within the same frame, there are caveats to that as the timeline will be moved by the full amount of characters used for the group, for example (abc)
will emit a
, b
and c
on the same frame but move the timeline by 5 frames
,|
represents the completion of an observable,^
represents the start of the subscription for the tested observable on a hot observable.For example, we could have:
-----a--|
will emit a value on frame 5 and complete on frame 8,-a-#
will emit on frame 1 and throw an error on frame 3,---a-(bc)--|
will emit a
on frame 3 and emit bc
on frame 5 and complete on frame 11,a 9ms b|
will start with a
then emit b
on frame 10 and complete on frame 11.To test the observable, we use expectObservable().toBe()
.
1
expectObservable(obs).toBe(expected);
The toBe
also accept a value argument which can be used to provide the object or arrays which would have the [a-z0-9]
marbles mapped to.
Knowing that, we can test more complex scenarios from observables, for example here we have an observable which uses bufferTime
where it buffers every 4 frames with a max item of 2.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
const sub = cold(" -(abc)---d--a-aab---|");
const expected = " -a---b---c---d-e---f(g|)";
expectObservable(sub.pipe(bufferTime(4, null, 2))).toBe(expected, {
a: ["a", "b"],
b: ["c"],
c: ["d"],
d: ["a"],
e: ["a", "a"],
f: ["b"],
g: [],
});
});
Which results in the following logs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
actual
Frame: 1 Kind N Value: [ 'a', 'b' ]
Frame: 5 Kind N Value: [ 'c' ]
Frame: 9 Kind N Value: [ 'd' ]
Frame: 13 Kind N Value: [ 'a' ]
Frame: 15 Kind N Value: [ 'a', 'a' ]
Frame: 19 Kind N Value: [ 'b' ]
Frame: 20 Kind N Value: []
Frame: 20 Kind C Value: undefined
expected
Frame: 1 Kind N Value: [ 'a', 'b' ]
Frame: 5 Kind N Value: [ 'c' ]
Frame: 9 Kind N Value: [ 'd' ]
Frame: 13 Kind N Value: [ 'a' ]
Frame: 15 Kind N Value: [ 'a', 'a' ]
Frame: 19 Kind N Value: [ 'b' ]
Frame: 20 Kind N Value: []
Frame: 20 Kind C Value: undefined
✓ BufferTime
The logs are self explanatory, on top of that we can see the behaviour of the buffer functions when given an interval and a max item where we can see that at a
on frame 1 received two values, hence emit the buffered values, then restart the interval for 4 frames. Similarly at e
on frame 15, the same situation occurs where the value is directly emitted. We also ensure that the completion triggers a final value, an empty array here since there is nothing in the buffer.
And that concludes today’s post!
In today’s post we saw how we could leverage Marble testing for testing observables. We started by looking at the API provided by TestScheduler
, we then moved on to look at the Marble syntax and looked at the meaning of each character used in the grammar. I hope you liked this post and I see you on the next one!