3
votes

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
Your sample code has some inconsistencies in spelling, but I'm assuming that's not the problem. It could very well be an issue with ticks-- you need to wait a tick for the JS engine to be able to act on the change message because JS isn't truly multithreaded. I would not use the subject's value to toggle-- I'd use a separate field with instant state changes, toggle that field, and then send the new toggle value as a message in the .next call.ps2goat
Also, note that Angular has some prebuilt testing classes for working with things that need ticks: tick, which is also a part of the @angular/core testing library.ps2goat
@ps2goat Thanks for the heads up on the inconsistencies. I was hastily writing directly in SO. I was hoping to avoid using tick() and the fakeAsync stuff as it's always felt a little dirty to me.. eg: delaying for some arbitrary amount of time in hopes that a task finishes, but if using marble testing isn't possible I'll have to go that routeaasukisuki
@ps2goat I don't think there is a problem with ticks and related stuff, because there is no async action involved.Andrei Gătej

1 Answers

0
votes

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).