使用异步管道对 ngIf 进行 jasmine 大理石测试(ColdObservable 与 Observable)

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

问题

我正在尝试找到一种使用大理石测试来测试异步管道副作用的方法。我在 Stackblitz 中创建了一个简单的 POC,所以你可以自己测试 https://stackblitz.com/edit/angular-ivy-pzbtqx?file=src/app/app.component.spec.ts

我正在使用服务方法的结果,该方法 return 是对象或 null 的 Observable(请参阅组件文件),并且 *ngIf directiveasync pipe 来显示或隐藏一些 html 元素,这取决于方法的结果是一个对象还是空(参见 html 文件)。

现在我想使用大理石测试为上述案例创建一个单元测试但是当我使用cold 可以从我的模拟服务中观察到 return 值。它总是被 async pipe.

解释为 null(或者更确切地说 falsy

html

<h1>Marble Tests POC</h1>

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

组件

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

  constructor(private myService: MyService) {}

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

规格

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

可能的解释:

我认为问题在于 async 管道 似乎根本无法与 ColdObservable 一起使用,或者至少它似乎是以不同于正常 Observables 的方式工作。现在我知道这可以在没有大理石测试的情况下进行测试;这是使用 fakeAsyncdone 函数的旧方法,但我喜欢使用大理石测试,因为推理起来更简单。

背景

我从 Angular - Component testing scenarios 文档中给出的示例中想到了这个想法,该文档给出了以下带有 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();
});

如你所见。他们使用 flush() 方法 运行 coldObservable 然后使用 detectChanges() 方法更新视图。

P.S.

在某人将 链接为重复之前,请注意该问题没有很好的答案,并且 OP 没有 post 全面解决他的问题

感谢 akotech 我在下面提供的答案!

解决方案:

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();
});

解释:

我们正在修改来自模拟服务的 returned Observable

beforeEach回调中,我们return从of运算符Observable (我们称之为 Obs1)。然后我们在实际测试中修改这个 return 值 return 现在 TestColdObservable (我们将称之为 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
  // ...
}

我们知道在我们的测试之前要执行的第一件事是 beforeEach 回调 并且在多个 回调的情况下 他们是按顺序执行的。所以首先我们将 mock 设置为 return Obs1 然后我们调用 createComponent()detectChanges() 进而调用 ngOnInit()并分别刷新视图。刷新视图时,async pipe 订阅由 mock 编辑的 Obs1 return。

执行 beforeEach 回调后。我们开始执行实际测试,我们做的第一件事是将 mock 的 returned 值修改为现在的 return Obs2。然后我们调用ngOnInit方法来改变observablevalue$所以它指向Obs2。但是,async pipe 没有更新视图,而是更新了它对 Obs2 的订阅。我们继续 flush observables 离开 async pipe 指向 Obs1 而不是 Obs2;

图表:

原创
   [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 ------------------------------------------------------------------
固定

   [value$] ------------(Obs1)------------------------------------(Obs2)----------------------|----------------------
[asyncPipe] -----------------------------(Obs1)------------------------(We subscribe first)---|--------(Obs2)--------
            |              |                |            |           |        (Obs2)          |           |
            ^              ^                ^            ^           ^           ^            ^           ^
       returnValue  createComponent  detectChanges  returnValue   ngOnInit  detectChanges  |flush!|  detectChanges
         (Obs1)        (ngOnInit)                      (Obs2)                       
       beforeEach      beforeEach2--------------------- test --------------------------------------------------------