Search code examples
angulartypescriptrxjsjasmine-marblesasync-pipe

Use jasmine marble testing for ngIf with async pipes (ColdObservable vs Observable)


The problem

I'm trying to find a way of using marble testing to test side efects with async pipes. I've created a simple POC in Stackblitz so you may test it for yourselves https://stackblitz.com/edit/angular-ivy-pzbtqx?file=src/app/app.component.spec.ts

I'm using the result from a service method, which returns an Observable of either an object or null (see component file), and the *ngIf directive with an async pipe to either display or hide some html element depending wether the result from the method was an object or null (see html file).

Now I would like to create a Unit Test for the aforementioned case using marble testing however when I use the cold observable as the return value from my mocked service. It is allways being interpreted as null (or to be more exact falsy) by the async pipe.

html

<h1>Marble Tests POC</h1>

<div id="conditional" *ngIf="value$ | async">
  <p>My Conditional message!</p>
</div>

component

export class AppComponent implements OnInit {
  value$: Observable<{} | null>;

  constructor(private myService: MyService) {}

  ngOnInit(): void {
    this.value$ = this.myService.getValue(false);
  }
}

spec

describe('AppComponent', () => {
  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;

  let mockedMyService = new MyServiceMock();
  let getValueSpy: jasmine.Spy;

  beforeEach(() => {
    getValueSpy = spyOn(mockedMyService, 'getValue').and.returnValue(of({}));
  });

  beforeEach(async () => {
    // module definition providers and declarations...
  });

  beforeEach(() => {
    // fixture and component initialization...
  });

  it('should display message when service returns different than null', () => {
    const testCase$ = cold('a', { a: {} });

    // if you comment the following line or use a normal Observable [of({})] instead of the 
    // coldObservable the test passes without issues.
    getValueSpy.and.returnValue(testCase$); 
    component.ngOnInit();

    getTestScheduler().flush();
    fixture.detectChanges();

    const conditionalComponent = fixture.debugElement.query(
      By.css('#conditional')
    );

    expect(conditionalComponent).not.toBeNull(); // Expected null not to be null.
  });
});

Possible explanation:

I'm thinking the issue is that the async pipe seems not to work with ColdObservables at all or at least it seems to be working in a different way than with normal Observables. Now I know this can be tested without marble testing; that is the old way with fakeAsync or done function, but I would love to use marble testing since is way simpler to reason about.

Background

I came up with this idea from the example given on the Angular - Component testing scenarios documentation which gives the following testCase with jasmine-marbles:

it('should show quote after getQuote (marbles)', () => {
  // observable test quote value and complete(), after delay
  const q$ = cold('---x|', { x: testQuote });
  getQuoteSpy.and.returnValue( q$ );

  fixture.detectChanges(); // ngOnInit()
  expect(quoteEl.textContent)
    .withContext('should show placeholder')
    .toBe('...');

  getTestScheduler().flush(); // flush the observables

  fixture.detectChanges(); // update view

  expect(quoteEl.textContent)
    .withContext('should show quote')
    .toBe(testQuote);
  expect(errorMessage())
    .withContext('should not show error')
    .toBeNull();
});

As you can see. they use the flush() method to run the coldObservable and then use the detectChanges() method to update the view.

P.S.

Before someone links Jasmine marble testing observable with ngIf async pipe as duplicate please note that question does not have a good answer and the OP did not post a comprehensive solution to his problem


Solution

  • Thanks to akotech for the answer I'm providing bellow!

    Solution:

    it('should display message when service returns different than null', () => {
      const testCase$ = cold('a-b-a', { a: {}, b: null });
    
      getValueSpy.and.returnValue(testCase$);
    
      component.ngOnInit();
      // Add the following detectChanges so the view is updated and the async pipe 
      // subscribes to the new observable returned above
      fixture.detectChanges();
    
      getTestScheduler().flush();
      fixture.detectChanges();
    
      const conditionalComponent = fixture.debugElement.query(
        By.css('#conditional')
      );
    
      expect(conditionalComponent).not.toBeNull();
    });
    

    Explanation:

    We are modifying the returned Observable from the mocked service.

    In the beforeEach callback we are returning an Observable from the of operator (we will call this Obs1). Then we are modifying this return value on the actual test returning now the TestColdObservable (we shall call this Obs2).

    beforeEach(() => {
      getValueSpy = spyOn(mockedMyService, 'getValue').and.returnValue(of({}));
      //                                                             --^-- 
      //                                                              Obs1
    });
    
    beforeEach(() => {
      fixture = TestBed.createComponent(AppComponent);
      // ...
      fixture.detectChanges();
    });
    
    // ...
    
    it('should display message when service returns different than null', () => {
      const testCase$ = cold('a', { a: {} }); // Obs2 definition
    
      getValueSpy.and.returnValue(testCase$);
      //                            --^--
      //                             Obs2
      component.ngOnInit();
    
      getTestScheduler().flush(); // We flush instead of update the async pipe's subscription
      // ...
    }
    

    We know that the first thing to be executed before our tests is the beforeEach callback and in case of multiple callbacks they are executed in order. So first we set the mock to return Obs1 then we call createComponent() and detectChanges() which in turn invokes ngOnInit() and refreshes the view respectively. When the view is refreshed the async pipe subscribes to the Obs1 returned by the mock.

    After executing the beforeEach callback. We start executing the actual test and the first thing we do is modify the returned value of the mock to now return Obs2. Then we call the ngOnInit method to change the observable value$ so it points to Obs2. However, instead of updating the view so the async pipe updates it's subscription to Obs2. We proceeded to flush the observables leaving the async pipe pointing to Obs1 rather than Obs2;

    Diagram:

    Original
       [value$] ------------(Obs1)--------------------------------------(Obs2)------|------------------------------------------------
    [asyncPipe] -----------------------------(Obs1)---------------------------------|-(we subscribe to an already flushed observable)
                ^              ^                ^              ^           ^        ^               ^
           returnValue  createComponent  detectChanges    returnValue   ngOnInit |flush!|      detectChanges
             (Obs1)        (ngOnInit)                        (Obs2)                       
           beforeEach      beforeEach2----------------------- test ------------------------------------------------------------------
    
    Fixed
    
       [value$] ------------(Obs1)------------------------------------(Obs2)----------------------|----------------------
    [asyncPipe] -----------------------------(Obs1)------------------------(We subscribe first)---|--------(Obs2)--------
                |              |                |            |           |        (Obs2)          |           |
                ^              ^                ^            ^           ^           ^            ^           ^
           returnValue  createComponent  detectChanges  returnValue   ngOnInit  detectChanges  |flush!|  detectChanges
             (Obs1)        (ngOnInit)                      (Obs2)                       
           beforeEach      beforeEach2--------------------- test --------------------------------------------------------