How to test a never-ending stream with Jasmine Marbles?

542 Views Asked by At

in my Angular service I have some code that performs polling to show a spinner until a certain condition is met:

@Inject({…})
export class SomeService {
  backendCall(): Observable<SomeStatusWrapper> {
    return this.http.get(…)
  }
  isDocumentReady(): Observable<boolean> {
    const hasFinished = new Subject<boolean>()
    return interval(1000).pipe(
      switchMap(idx => of(idx).pipe(delay(idx*250))), // Increment delay between retries
      switchMap(() => this.backendCall()),
      map(({status}) => status === 'Done'),
      tap(done => done?hasFinished.next(done) : undefined),
      distinctUntilChanged(),
      takeUntil(hasFinished),
      endWith(true)
    )
  }
}

Now, I'd like to add a unit test for this one and I am using Jasmine marbles, but I am not certain of how many frames there's going to be there and I can't get to get the unit test working.

How does one express an 'interval' with marble syntax? I know we can do something like cold('1000ms (a|)',{a:true}) where it waits 1000ms, emits "true" and completes at the same time.

I was assuming that this test would pass but it failed:

it('should emit false after 1000ms', () => {
  const service = TestBed.inject(service) // already mocked backendCall to return a status != 'Done'
  expect(service.isDocumentReady()).toBeObservable(cold('1000ms f', {f: false})) 
})

Simpler version

How do I write jasmine marbles to make this test pass?

describe('', () => {
  it('should pass', () => {
    const stream$ = interval(1000).pipe(
      switchMap(val => of(val).pipe(delay(250*val))),
      mapTo(false)
    )
    expect(stream$).toBeObservable(cold(???, {f:false}))
  })
})
1

There are 1 best solutions below

0
Ant Kutschera On

I think the problem is that the call to toBeObservable subscribes and asserts at the same time, but time needs to be advanced before the assertion is made. This assumption is based on debugging jasmine and jasmine-marbles to try and understand what is really happening under the hood.

Using the test scheduler, it is possible to reorganise the code so that the subscription starts and the assertion is made afterwards: expectObservable(partner$).toBe('500ms (a|)', {a})

I have an example of something similar working in an Angular app. There is a service which has the following methods which return observables:

getPartner() {
  return of(1).pipe(
    delay(500),
    map(() => this.newPartner())
  )
}

getPartnerUsingInterval() {
  return interval(500).pipe(
    take(1),
    map(() => this.newPartner())
  )
}

The following test passes:

import { discardPeriodicTasks, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { delay, interval, of, take } from 'rxjs';
import { InsuranceService } from './insurance.service';
import { Partner } from './model';

...

// idea from https://kevinkreuzer.medium.com/marble-testing-with-rxjs-testing-utils-3ae36ac3346a
it('getPartner should return after some time', fakeAsync(() => {

  // this example works when using interval, delay, etc.

  getTestScheduler().run(({cold, expectObservable}) => {

    tick(500) // call anytime before call to `expectObservable`
    const partner$ = service.getPartner();

    expectObservable(partner$).toBe('500ms (a|)', {a})
  })
}))

Compare it to the following tests, structured like the one from the OP, which don't work. The comments attempt to explain why.

// the following doesnt work, because it subscribes after the results are collected
xit('FAILS getPartner should return after some time', fakeAsync(() => {
  const partner$ = service.getPartner();
  const expected = cold('500ms (a|)', { a });
  tick(500); // TOO EARLY

  // doesn't work either:
  getTestScheduler().expectObservable(partner$) // doesnt seem to subscribe yet, probably because not inside `run`
        .toBe('500ms (a|)', {a})
  // with result:
  // Failed:
  //      Expected
  //
  //      to deep equal
  //        {"frame":500,"notification":{"kind":"N","value":{"id":"1","firstName":"John","lastName":"Smith","address":{"nr":1,"street":"Sesame Street","zip":"1000","city":"Lausanne","state":"Vaud"}}}}
  //        {"frame":500,"notification":{"kind":"C"}}
  //
  // which is saying that the actual value is totally empty and so not equal to the expectation
}));

// the following doesnt work, because it subscribes after the results are collected
xit('FAILS getPartner should return after some time', fakeAsync(() => {

  const partner$ = service.getPartner();

  tick(500); // TOO EARLY
  getTestScheduler().flush()
  flush();

  const expected = cold('500ms (a|)', { a });
  tick(500); // TOO EARLY

  expect(partner$) // presumably does not yet subscribe
    .toBeObservable(expected); // presumably subscribes and tests in one step, and tick() needs to be called after subscribing, as shown below when manually subscribing
  tick(500); // TOO LATE - it happens after the actual subscription. while debugging, we see that the subscription is used, but it's simply too late

  // fails with:
  // Expected $.length = 0 to equal 2.
  // Expected $[0] = undefined to equal Object({ frame: 500, notification: Object({ kind: 'N', value: Object({ id: '1', firstName: 'John', lastName: 'Smith', address: Object({ nr: 1, street: 'Sesame Street', zip: '1000', city: 'Lausanne', state: 'Vaud' }) }), error: undefined }) }).
  // Expected $[1] = undefined to equal Object({ frame: 500, notification: Object({ kind: 'C', value: undefined, error: undefined }) }).
  // meaning we expected it to contain a partner and a completion, but neither was present

  // useful if it fails because of: `Error: 1 periodic timer(s) still in the queue.`, but in this case it is not necessary if we add enough tick() calls
  // discardPeriodicTasks()
}));

The following test manually subscribes, and helps to explain why the above tests fail:

it('getPartner should return after some time using manual subscription', fakeAsync(() => {

  let partner: Partner | undefined
  const partner$ = service.getPartner();

  // this manual subscription works and shows that the tick() needs to be called after subscription
  partner$.subscribe(p => partner = p)
  expect(partner).toBe(undefined);
  tick(500);
  expect(partner).toEqual(a);
}));