Mikelgo
Mikelgo

Reputation: 575

angular rxjs marble test no values emitted

I try to test a simple angular component using a marble test. For that I'm using the TestScheduler which comes together with rxjs.

Here is a stackblitz link with the code: https://stackblitz.com/edit/angular-ivy-xwzn1z

This is a simplified version of my component:

@Component({
  selector: 'detail-component',
    template: ` <ng-container *ngIf="(detailsVisible$ | async)"> <p> detail visible </p> </ng-container>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class DetailComponent implements OnInit, OnDestroy {
  @Input() set isAdditionalContentVisible(isAdditionalContentVisible: boolean) {
      this.resetSubject.next(isAdditionalContentVisible);
  }
  private readonly resetSubject = new Subject<boolean>();
  private readonly toggleVisibilitySubject = new Subject<void>();
  private readonly destroySubject = new Subject();

  public detailsVisible$: Observable<boolean> = this.toggleVisibilitySubject.pipe(
    scan((state, _) => !state, false),
    startWith(false)
  );

  private readonly resetDetailsVisibilitySideEffect$: Observable<void> = this.resetSubject.asObservable().pipe(
    withLatestFrom(this.detailsVisible$),
    map(([resetTrigger, state]) => {
      if (state !== resetTrigger) {
        this.toggleVisibilitySubject.next();
      }
    })
  );

  constructor() {}

  ngOnInit(): void {
    this.resetDetailsVisibilitySideEffect$.pipe(takeUntil(this.destroySubject)).subscribe();
  }
  ngOnDestroy(): void {
    this.destroySubject.next();
    this.destroySubject.complete();
  }

  toggleAdditionalContentVisibility(): void {
    this.toggleVisibilitySubject.next();
  }
}

I want to test the detailsVisible$-observable.

For that I created following test:

import { TestScheduler } from 'rxjs/testing';

 describe('DetailComponent', () => {
    const debug = true;
    let scheduler: TestScheduler;
    let component: DetailComponent;

    beforeEach(() => {
      component = new DetailComponent();
      scheduler = new TestScheduler((actual, expected) => {
        // asserting the two objects are equal
        if (debug) {
          console.log('-------------------------------');
          console.log('Expected:\n' + JSON.stringify(expected, null, 2));
          console.log('Actual:\n' + JSON.stringify(actual, null, 2));
        }

        expect(actual).toEqual(expected);
      });
    });
    it('should finally work out', () => {
      scheduler.run((helpers) => {
        const { cold, hot, expectObservable, expectSubscriptions } = helpers;
        const values = {
          f: false,
          t: true
        };
        const toggleVisibilityValues = {
          v: void 0
        };
        const resetValues = {
          f: false,
          t: true
        };
        component.ngOnInit();
        // marbles
        // prettier-ignore
        const detailsVisibleMarble        = 'f-t-f-t-f-t-f';
        // prettier-ignore
        const toggleVisibilityMarble      = '--v-v-----v--';
        // prettier-ignore
        const resetMarble                 = '------t-f---f';

        // Mock observables
        (component as any).toggleVisibilitySubject = cold(toggleVisibilityMarble,toggleVisibilityValues);
        (component as any).resetSubject = cold(resetMarble, resetValues);

        // output
        expectObservable(component.detailsVisible$).toBe(detailsVisibleMarble, values);
      });
    });
  });
  

I tried several things but all are resulting in the follwing output:

  Expected $.length = 1 to equal 7.
    Expected $[1] = undefined to equal Object({ frame: 2, notification: Notification({ kind: 'N', value: true, error: undefined, hasValue: true }) }).
    Expected $[2] = undefined to equal Object({ frame: 4, notification: Notification({ kind: 'N', value: false, error: undefined, hasValue: true }) }).
    Expected $[3] = undefined to equal Object({ frame: 6, notification: Notification({ kind: 'N', value: true, error: undefined, hasValue: true }) }).
    Expected $[4] = undefined to equal Object({ frame: 8, notification: Notification({ kind: 'N', value: false, error: undefined, hasValue: true }) }).
    Expected $[5] = undefined to equal Object({ frame: 10, notification: Notification({ kind: 'N', value: true, error: undefined, hasValue: true }) }).
    Expected $[6] = undefined to equal Object({ frame: 12, notification: Notification({ kind: 'N', value: false, error: undefined, hasValue: true }) }).
    <Jasmine>

So somehow the source of detailsVisible$ (toggleVisibilitySubject) is never emitting any value (I only get the startWith-value in the result).

I do not see what I'm missing. The code itself works perfectly fine.

Thanks for any suggestions.

Edit: I also tried out to

toggle$ = this.toggleVisibilitySubject.asObservable();
public detailsVisible$ = this.toggle.pipe(...)

and in the test: component.toggle$ =cold(toggleVisibilityMarble,toggleVisibilityValues).

Upvotes: 1

Views: 1995

Answers (2)

Mikelgo
Mikelgo

Reputation: 575

So finally I found out what the problem was. The answer of @Andrei Gătej is partly correct. So indeed the problem is that when creating the component the existing subject instance is taken and not the mocked subjects.

The solution is the following:

@Component({
  selector: 'detail-component',
    template: ` <ng-container *ngIf="(detailsVisible$ | async)"> <p> detail visible </p> </ng-container>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class DetailComponent implements OnInit, OnDestroy {
  @Input() set isAdditionalContentVisible(isAdditionalContentVisible: boolean) {
      this.resetSubject.next(isAdditionalContentVisible);
  }
  private resetSubject = new Subject<boolean>();
  private toggleVisibilitySubject = new Subject<void>();
  private readonly destroySubject = new Subject();

  toggleVisibility$: Observable<void> =this.toggleVisibilitySubject.asObservable();
  reset$: Observable<boolean> = this.resetDetailsVisibilitySubject.asObservable();

  detailsVisible$: Observable<boolean>;

  resetDetailsVisibilitySideEffect$: Observable<void>;

  constructor() {}

  ngOnInit(): void {
        this.detailsVisible$ = this.toggleVisibility$.pipe(
      scan((state, _) => !state, false),
      startWith(false)
    );
    this.resetDetailsVisibilitySideEffect$ = this.reset$.pipe(
      withLatestFrom(this.detailsVisible$),
      map(([resetTrigger, state]) => {
        if (state !== resetTrigger) {
          this.toggleVisibilitySubject.next();
        }
      })
    );
    this.resetDetailsVisibilitySideEffect$.pipe(takeUntil(this.destroySubject)).subscribe();
  }
  ngOnDestroy(): void {
    this.destroySubject.next();
    this.destroySubject.complete();
  }

  toggleAdditionalContentVisibility(): void {
    this.toggleVisibilitySubject.next();
  }
}

Note: the observables are now wired together in ngOnInit().

In the test it is then important that ngOnInit() will be called after the observables have been mocked.

Upvotes: 0

Andrei Gătej
Andrei Gătej

Reputation: 11979

I think the problem is that when the component is created(in beforeEach()), detailsDivible$ will already be created based on an existing Subject instance.

It would be loosely the same as doing this:

// the initial `toggleVisibilitySubject`
f = () => 'a';

// when the component is created
details = f();

// (component as any).toggleVisibilitySubject = cold(...)
f = () => 'b';

details // "a"

With this in mind, I think one approach would be:

scheduler.run(helpers => {
  /* ... */

  const detailsVisibleMarble        = 'f-t-f-t-f-t-f';
  // prettier-ignore
  const toggleVisibilityMarble      = '--v-v-----v--';
  // prettier-ignore
  const resetMarble                 = '------t-f---f';


  const toggleEvents$ = cold(toggleVisibilityMarble,toggleVisibilityValues)

  const src$ = merge(
    toggleEvents$.pipe(
      tap(value => (component as any).toggleVisibilitySubject.next(undefined)),
      
      // we just want to `feed` the subject, we don't need the value of the events
      ignoreElements(),
    ),
    // the observable whose values we're interested in
    component.detailsVisible$,
  )

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

This might not work yet, as I think toggleVisibilityMarble and detailsVisibleMarble do not match. So I'd change detailsVisibleMarble to

const detailsVisibleMarble = 'f-t-f-----t--';

Upvotes: 0

Related Questions