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:
ObjectStateContainer
.
$isDirty
is false.ObjectStateContainer
.
$isDirty
is true.ObjectStateContainer
.
$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);
});
});
});
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:
frame 0
: a
(false)frame 4
: b
(true)frame 10
: c
(false)Where are these frame numbers coming from?
frame 0
and that at moment the class is barely initializedcold('----(b|))
- will emit t2
at frame 4cold('----------(c|)')
- will call objectStateContainer.undoChanges()
at frame 10