1

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

Mikelgo
  • 483
  • 4
  • 15

2 Answers2

0

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--';
Andrei Gătej
  • 11,116
  • 1
  • 14
  • 31
  • Thanks for the suggestions, I'll try this out. But I don't think that it is a problem that `detailsVisible$` is already created. As long as there's no subscriber no values are lost (and the test compares the observables not the subscriptions). Somehow just the trigger (toggleVisibilitySubject) seems to not emit anything therefore I do not get any futher values. So kind of the mocking part is wrong I guess – Mikelgo Jul 27 '20 at 11:17
  • Have you tried out my idea? As far as the mocking part is concerned, I think `detailsVisibleMarble` does not match with `toggleVisibilityMarble`. – Andrei Gătej Jul 27 '20 at 12:18
  • I tried it out but unfortunately it did not work out for me. – Mikelgo Jul 27 '20 at 12:22
  • Same 'error', that `toggleVisibilitySubject` emits only once? – Andrei Gătej Jul 27 '20 at 12:23
  • It would be great if you could provide more details about the errors you’re getting – Andrei Gătej Jul 28 '20 at 07:25
0

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.

Mikelgo
  • 483
  • 4
  • 15