Marble testing rxjs observable on an angular service

Viewed 733

I have an Angular service with an exposed observable that I'm trying to marble test with the rxjs TestScheduler. A method on the service controls the value the observable emits, using a BehaviorSubject as the source. Here's a really simple example:

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { BehaviorSubject } from "rxjs";

@Injectable()
export class MyService {
  private _isVisibleSubject: BehaviorSubject<boolean> = new BehaviorSubject<
    boolean
  >(true);

  isVisible$: Observable<boolean> = this._isVisibleSubject.asObservable();

  constructor() {}

  toggleVisibility() {
    this._isVisibleSubject.next(!this._isVisibleSubject.value);
  }
}

A simple unit test for MyService. I want to test 2 conditions:

  1. The isVisible$ observable starts with true
  2. When toggleVisibility is called twice, isVisible$ has 3 values, true, false, and back to true

Here's the test class

import { TestBed } from '@angular/core/testing';
import { TestScheduler } from 'rxjs/testing';

import { MyService } from './my.service';

describe('MyService ', () => {

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

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [MyService] });
    service = TestBed.inject(MyService);
  });

  it('Should start out visible', () => {

    // This one is easy enough, it starts out visible and nothing else happens

    testScheduler.run((helpers) => {

      const { expectObservable } = helpers;

      const values: {[key: string]: boolean} = {'a': true};
      const expected = 'a';

      expectObservable(service.isVisible$).toBe(expected, values);
    });


  it('Should toggle visibility back and forth', () => {

    testScheduler.run((helpers) => {

      const { expectObservable } = helpers;

      const values: {[key: string]: boolean} = {'a': true, 'b': false};
      const expected = 'aba';  // I've also tried with various frames between, eg '-a-b-a'

      service.toggleVisibility();
      service.toggleVisibility();

      expectObservable(service.isVisible$).toBe(expected, values);
    });
  });

When I run this, the first test passes, but the second fails with an error like Expected $.length = 1 to equal 3. I have verified that the value actually changes by running a test like:

    testScheduler.run((helpers) => {

      const { expectObservable } = helpers;

      const values: {[key: string]: boolean} = {'b': false};
      const expected = 'b';

      service.toggleVisibility();

      expectObservable(service.isVisible$).toBe(expected, values);
    });

I'm assuming the issue is that service.isVisible$ isn't subscribed to until expectObservable() is called, so the previous values are lost. I looked through the rxjs documentation on marble testing but can't figure this out.

I realize I could just set up a subscription manually to the isVisible$ observable when the test starts, and verify state along the way as I make changes, but that doesn't feel as nice as using the marble testing.

Is what I want to do possible?

1 Answers

It's a very interesting problem, I'll share my perspective as to why it doesn't work.

So, when these lines are reached:

service.toggleVisibility();
service.toggleVisibility();

the service.isVisible$ observable has not yet been subscribed to. More concretely, the BehaviorSubject it derives from has no subscribers.

Then, when the line

expectObservable(service.isVisible$).toBe(expected, values);

is reached, the BehaviorSubject finally has its first subscriber and will emit the right value. The test does not pass because you're expecting 3 values to have been emitted, but in reality there is only one.

A way to solve this problem might be:

 it('Should toggle visibility back and forth', () => {
  testScheduler.run((helpers) => {

    const { expectObservable } = helpers;

    const values: {[key: string]: boolean} = {'a': true, 'b': false};
    const expected = 'aba';

    const src$ = merge(
      // the other `source` which defines the changes in time
      of(...expected).pipe(
        // only 2 toggle operations are needed
        // because `service.isVisible$` starts with `true`
        take(2),
        
        tap(v => service.toggleVisibility())

        // with this, `.next()` notifications will be ignored
        // which is what we want, because we only subscribed to this in order to
        // simulate changes that occur in time
        ignoreElements(),
      )
      
      // the observable whose values we want to examine
      service.isVisible$,
    )

    expectObservable(src$).toBe(expected, values);
  });
})

Note that if you want to have more complex timeframes, like a---b---a---10s--c, you can replace of(...) with cold(timePattern, values).

Related