rxjsrxjs-marbles

RxJs test for multiple values from the stream


Given the following class:

import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';

export class ObjectStateContainer<T> {
    private currentStateSubject = new BehaviorSubject<T>(undefined);
    private $currentState = this.currentStateSubject.asObservable();

    public $isDirty = this.$currentState.pipe(map(t => t !== this.t));

    constructor(public t: T) {
        this.update(t);
    }

    undoChanges() {
        this.currentStateSubject.next(this.t);
    }

    update(t: T) {
        this.currentStateSubject.next(t);
    }
}

I would like to write some tests validating that $isDirty contains the value I would expect after performing various function invocations. Specifically I would like to test creating a variable, updating it and then undoing changes and validate the value of $isDirty for each stage. Currently, I've seen two way of testing observables and I can't figure out how to do this test with either of them. I would like the test to do the following:

  1. Create a new ObjectStateContainer.
    • Assert that $isDirty is false.
  2. Invoke update on the ObjectStateContainer.
    • Assert that $isDirty is true.
  3. Invoke undoChanges on the ObjectStateContainer.
    • Assert that $isDirty is false.

import { ObjectStateContainer } from './object-state-container';
import { TestScheduler } from 'rxjs/testing';

class TestObject {
}

describe('ObjectStateContainer', () => {
    let scheduler: TestScheduler;

    beforeEach(() =>
        scheduler = new TestScheduler((actual, expected) =>
        {
            expect(actual).toEqual(expected);
        })
    );

    /*
        SAME TEST AS ONE BELOW
        This is a non-marble test example.
    */
    it('should be constructed with isDirty as false', done => {
        const objectStateContainer = new ObjectStateContainer(new TestObject());
        objectStateContainer.update(new TestObject());
        objectStateContainer.undoChanges();

        /*
            - If done isn't called then the test method will finish immediately without waiting for the asserts inside the subscribe.
            - Using done though, it gets called after the first value in the stream and doesn't wait for the other two values to be emitted.
            - Also, since the subscribe is being done here after update and undoChanges, the two previous values will already be gone from the stream. The BehaviorSubject backing the observable will retain the last value emitted to the stream which would be false here.
            I can't figure out how to test the whole chain of emitted values.
        */
        objectStateContainer
            .$isDirty
            .subscribe(isDirty => {
                expect(isDirty).toBe(false);
                expect(isDirty).toBe(true);
                expect(isDirty).toBe(false);
                done();
            });
    });

    /*
        SAME TEST AS ONE ABOVE
        This is a 'marble' test example.
    */
    it('should be constructed with isDirty as false', () => {
        scheduler.run(({ expectObservable }) => {
            const objectStateContainer = new ObjectStateContainer(new TestObject());
            objectStateContainer.update(new TestObject());
            objectStateContainer.undoChanges();

         /*
            - This will fail with some error message about expected length was 3 but got a length of one. This seemingly is happening because the only value emitted after the 'subscribe' being performed by the framework is the one that gets replayed from the BehaviorSubject which would be the one from undoChanges. The other two have already been discarded.
            - Since the subscribe is being done here after update and undoChanges, the two previous values will already be gone from the stream. The BehaviorSubject backing the observable will retain the last value emitted to the stream which would be false here.
            I can't figure out how to test the whole chain of emitted values.
        */
            const expectedMarble = 'abc';
            const expectedIsDirty = { a: false, b: true, c: false };
            expectObservable(objectStateContainer.$isDirty).toBe(expectedMarble, expectedIsDirty);
        });
});
});

Solution

  • I'd opt for marble tests:

    scheduler.run(({ expectObservable, cold }) => {
      const t1 = new TestObject();
      const t2 = new TestObject();
      const objectStateContainer = new ObjectStateContainer(t1);
    
      const makeDirty$ =  cold('----(b|)', { b: t2 }).pipe(tap(t => objectStateContainer.update(t)));
      const undoChange$ = cold('----------(c|)', { c: t1 }).pipe(tap(() => objectStateContainer.undoChanges()));
      const expected = '        a---b-----c';
      const stateValues = { a: false, b: true, c: false };
    
      const events$ = merge(makeDirty$, undoChange$);
      const expectedEvents = '  ----b-----(c|)';
    
      expectObservable(events$).toBe(expectedEvents, { b: t2, c: t1 });
      expectObservable(objectStateContainer.isDirty$).toBe(expected, stateValues);
    });
    

    What expectObservable does is to subscribe to the given observable and turn each value/error/complete event into a notification, each notification being paired with the time frame at which it had arrived(Source code).

    These notifications(value/error/complete) are the results of an action's task. An action is scheduled into a queue. The order in which they are queued is indicated by the virtual time.

    For example, cold('----(b|)') means: at frame 4 send the value b and a complete notification. If you'd like to read more about how these actions and how they are queued, you can check out this SO answer.


    In our case, we're expecting: a---b-----c, which means:

    Where are these frame numbers coming from?