Rxjs marble testing : how to simulate user interaction with observables

905 Views Asked by At

I want to test an Angular service, I will simplify my case in order to make it straightforward :

I have a behaviorSubject bound to an observable in my service

@Injectable({
  providedIn: 'root',
})
export class MyService {
    readonly username = new BehaviorSubject<string>('');
    readonly username$ = this.username.asObservable();

    constructor() {}
}

What I want to test is a simple user interaction on filling the username multiple times :

describe('MyService', () => {
    let myService: MyService;

    const testScheduler = new TestScheduler((actual, expected) => {
        expect(actual).toEqual(expected);
    });

    beforeEach(() => {
        TestBed.configureTestingModule({
            imports: [HttpClientTestingModule],
            providers: [{ provide: MyService, useValue: myService }]
        });

        httpTestingController = TestBed.inject(HttpTestingController);
    });

    afterEach(() => {
        // After every test, assert that there are no more pending requests.
        httpTestingController.verify();
    });

    it('should be created', inject([MyService], (service: MyService) => {
        expect(service).toBeTruthy();
    }));

    it('should be toto lala', inject([MyService], (service: MyService) => {
        testScheduler.run(helpers => {
            const { expectObservable, cold } = helpers;
            service.username.next("toto");
            service.username.next("lala");
            const expect$ = "ab";
            expectObservable(service.username$).toBe(expect$, {
                a: "toto",
                b: "lala",
            })
        });
    }));

But when I launch this test, only the last value emitted by the subject is in the observable.

Chrome 97.0.4692.99 (Windows 10) MyService should be toto lala FAILED
        Expected $.length = 1 to equal 2.
        Expected $[0].notification.value = 'lala' to equal 'toto'.
        Expected $[1] = undefined to equal Object({ frame: 1, notification: Notification({ kind: 'N', value: 'lala', error: undefined, hasValue: true }) }).
        Error: Expected $.length = 1 to equal 2.
        Expected $[0].notification.value = 'lala' to equal 'toto'.
        Expected $[1] = undefined to equal Object({ frame: 1, notification: Notification({ kind: 'N', value: 'lala', error: undefined, hasValue: true }) }).
            at <Jasmine>
            at TestScheduler.assertDeepEqual (mycomponent.component.spec.ts:71:24)
            at node_modules/rxjs/_esm2015/internal/testing/TestScheduler.js:110:1
            at <Jasmine>
Chrome 97.0.4692.99 (Windows 10): Executed 1 of 7 (1 FAILED) (0 secs / 0.024 secs)
Chrome 97.0.4692.99 (Windows 10) MyService should be toto lala FAILED
        Expected $.length = 1 to equal 2.
        Expected $[0].notification.value = 'lala' to equal 'toto'.
        Expected $[1] = undefined to equal Object({ frame: 1, notification: Notification({ kind: 'N', value: 'lala', error: undefined, hasValue: true }) }).
        Error: Expected $.length = 1 to equal 2.
        Expected $[0].notification.value = 'lala' to equal 'toto'.
        Expected $[1] = undefined to equal Object({ frame: 1, notification: Notification({ kind: 'N', value: 'lala', error: undefined, hasValue: true }) }).
            at <Jasmine>
            at TestScheduler.assertDeepEqual (mycomponent.component.spec.ts:71:24)
            at node_modules/rxjs/_esm2015/internal/testing/TestScheduler.js:110:1
Chrome 97.0.4692.99 (Windows 10): Executed 2 of 7 (1 FAILED) (skipped 5) (0.126 secs / 0.029 secs)
TOTAL: 1 FAILED, 1 SUCCESS
TOTAL: 1 FAILED, 1 SUCCESS
npm ERR! Test failed.  See above for more details.

Is there a way to get 2 values in the observable without changing the BehaviorSubject to a ReplaySubject ?

2

There are 2 best solutions below

0
Joosep Parts On

No that is not possible, behaviourSubject can cache only last value. If you just want to test the subscription and for redundancy I see you next twice, why not next and expect it right after but do it twice.

0
Martyn On

The problem here is that your expectObservable is subscribing (and verifying the emitted values) too late. The only reason it gets any emitted value at all is because you've used a BehaviorSubject. Which will, by design, emit the current value to any new/late subscribers.

Also.. When calling next() twice, both of these emissions will happen at the same moment in time. Whereas your test is expecting that b: "lala" will occur one frame later.

If you need to simulate user input occuring at certain timeframes in 'marble-time' then you can do something like this:

hot("ab").subscribe((value) => {
   if(value === "a") service.username.next("toto");
   if(value === "b") service.username.next("lala");
});    

const expect$ = "ab";
expectObservable(service.username$).toBe(expect$, {
    a: "toto",
    b: "lala",
})

This ensures that your simulated user clicks both happen at specific (and different) time frames in marble time, and therefore match your expected output observable timing.

Ultimately though, this test is a bit redundant. You're just artificially making an observable behave a certain way and then verifying that it behaves as requested. It's not actually testing anything about the service itself. More useful would be a test that looks like this:

hot("abc").subscribe((value) => {
   if(value === "a") service.logInUser("toto", "toto's password");
   if(value === "b") service.logInUser("toto", "wrong password");
   if(value === "c") service.logInUser("lala", "lala's password");
});    

const expect$ = "a-b";
expectObservable(service.username$).toBe(expect$, {
    a: "toto",
    b: "lala",
})