Testing Rxjs Observable With Marble Testing Angular NGRX

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.

TestScheduler

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.

Marble Syntax

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!

Conclusion

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!

Designed, built and maintained by Kimserey Lam.