web-dev-qa-db-ja.com

なぜdetectChanges / whenStableを2回呼び出す必要があるのですか?

最初の例

私は次のテストを受けました:

_import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { Component } from '@angular/core';

@Component({
    template: '<ul><li *ngFor="let state of values | async">{{state}}</li></ul>'
})
export class TestComponent {
    values: Promise<string[]>;
}

describe('TestComponent', () => {
    let component: TestComponent;
    let fixture: ComponentFixture<TestComponent>;
    let element: HTMLElement;

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [TestComponent]
        })
            .compileComponents();
    }));

    beforeEach(() => {
        fixture = TestBed.createComponent(TestComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
        element = (<HTMLElement>fixture.nativeElement);
    });

    it('this test fails', async() => {
        // execution
        component.values = Promise.resolve(['A', 'B']);
        fixture.detectChanges();
        await fixture.whenStable();

        // evaluation
        expect(Array.from(element.querySelectorAll('li')).map(elem => elem.textContent)).toEqual(['A', 'B']);
    });

    it('this test works', async() => {
        // execution
        component.values = Promise.resolve(['A', 'B']);
        fixture.detectChanges();
        await fixture.whenStable();
        fixture.detectChanges();
        await fixture.whenStable();

        // evaluation
        expect(Array.from(element.querySelectorAll('li')).map(elem => elem.textContent)).toEqual(['A', 'B']);
    });
});
_

ご覧のとおり、Promiseによって提供されるアイテムのリストを表示するだけの非常にシンプルなコンポーネントがあります。 2つのテストがあり、1つは失敗し、もう1つは合格です。これらのテストの唯一の違いは、合格したテストがfixture.detectChanges(); await fixture.whenStable();を2回呼び出すことです。

更新:2番目の例(2019/03/21に再度更新)

この例では、ngZoneとの関係の可能性を調査します。

_import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { Component, NgZone } from '@angular/core';

@Component({
    template: '{{value}}'
})
export class TestComponent {
    valuePromise: Promise<ReadonlyArray<string>>;
    value: string = '-';

    set valueIndex(id: number) {
        this.valuePromise.then(x => x).then(x => x).then(states => {
            this.value = states[id];
            console.log(`value set ${this.value}. In angular zone? ${NgZone.isInAngularZone()}`);
        });
    }
}

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

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            imports: [FormsModule],
            declarations: [TestComponent],
            providers: [
            ]
        })
            .compileComponents();
    }));

    beforeEach(() => {
        fixture = TestBed.createComponent(TestComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    function diagnoseState(msg) {
        console.log(`Content: ${(fixture.nativeElement as HTMLElement).textContent}, value: ${component.value}, isStable: ${fixture.isStable()} # ${msg}`);
    }

    it('using ngZone', async() => {
        // setup
        diagnoseState('Before test');
        fixture.ngZone.run(() => {
            component.valuePromise = Promise.resolve(['a', 'b']);

            // execution
            component.valueIndex = 1;
        });
        diagnoseState('After ngZone.run()');
        await fixture.whenStable();
        diagnoseState('After first whenStable()');
        fixture.detectChanges();
        diagnoseState('After first detectChanges()');
    });

    it('not using ngZone', async(async() => {
        // setup
        diagnoseState('Before setup');
        component.valuePromise = Promise.resolve(['a', 'b']);

        // execution
        component.valueIndex = 1;

        await fixture.whenStable();
        diagnoseState('After first whenStable()');
        fixture.detectChanges();
        diagnoseState('After first detectChanges()');

        await fixture.whenStable();
        diagnoseState('After second whenStable()');
        fixture.detectChanges();
        diagnoseState('After second detectChanges()');

        await fixture.whenStable();
        diagnoseState('After third whenStable()');
        fixture.detectChanges();
        diagnoseState('After third detectChanges()');
    }));
});

_

これらの最初のテスト(明示的にngZoneを使用)の結果:

_Content: -, value: -, isStable: true # Before test
Content: -, value: -, isStable: false # After ngZone.run()
value set b. In angular zone? true
Content: -, value: b, isStable: true # After first whenStable()
Content: b, value: b, isStable: true # After first detectChanges()
_

2番目のテストログ:

_Content: -, value: -, isStable: true # Before setup
Content: -, value: -, isStable: true # After first whenStable()
Content: -, value: -, isStable: true # After first detectChanges()
Content: -, value: -, isStable: true # After second whenStable()
Content: -, value: -, isStable: true # After second detectChanges()
value set b. In angular zone? false
Content: -, value: b, isStable: true # After third whenStable()
Content: b, value: b, isStable: true # After third detectChanges()
_

テストがangular=ゾーンで実行されることを少し期待していましたが、そうではありません。問題は、

驚きを避けるために、then()に渡される関数は、すでに解決済みのプロミスがある場合でも、同期的に呼び出されることはありません。 ( ソース

この2番目の例では、.then(x => x)を複数回呼び出すことで問題を引き起こしました。これは、進行状況をブラウザーのイベントループに再度入れ、結果を遅らせるだけです。これまでの私の理解では、await fixture.whenStable()の呼び出しは、基本的に「そのキューが空になるまで待機する」と言う必要があります。 ngZoneでコードを明示的に実行すると、これは実際に機能します。ただし、これはデフォルトではなく、テストをそのように記述することを意図したマニュアルのどこにも見つけることができないため、これは扱いにくいと感じます。

2番目のテストで実際にawait fixture.whenStable()は何をしますか? ソースコード は、この場合fixture.whenStable()return Promise.resolve(false);になることを示しています。したがって、私は実際にawait fixture.whenStable()await Promise.resolve()で置き換えようとしましたが、実際には同じ効果があります。これは、テストを一時停止し、イベントキューで開始する効果があるため、コールバックに渡されますvaluePromise.then(...)が実際に実行されるのは、約束どおりにawaitを呼び出すだけで十分な場合です。

await fixture.whenStable();を複数回呼び出す必要があるのはなぜですか?私はそれを間違って使用していますか?これは意図された動作ですか?これがどのように機能するか/どのように対処するかについての「公式」文書はありますか?

16
yankee

_Delayed change detection_が発生していると思います。

遅延変更の検出は意図的で便利です。 Angularがデータバインディングを開始し、ライフサイクルフックを呼び出す前に、コンポーネントの状態を検査および変更する機会をテスターに​​与えます。

detectChanges()


_Automatic Change Detection_を実装すると、両方のテストでfixture.detectChanges()を1回だけ呼び出すことができます。

_ beforeEach(async(() => {
            TestBed.configureTestingModule({
                declarations: [TestComponent],
                providers:[{ provide: ComponentFixtureAutoDetect, useValue: true }] //<= SET AUTO HERE
            })
                .compileComponents();
        }));
_

Stackblitz

https://stackblitz.com/edit/directive-testing-fnjjqj?embed=1&file=app/app.component.spec.ts

_Automatic Change Detection_の例のこのコメントは重要であり、AutoDetectを使用しても、テストでfixture.detectChanges()を呼び出す必要がある理由は重要です。

2番目と3番目のテストは、重要な制限を明らかにします。 Angularテスト環境は、テストがコンポーネントのタイトルを変更したことを認識していません。ComponentFixtureAutoDetectサービスは、promise解決、タイマー、DOMイベントなどの非同期アクティビティに応答します。ただし、直接の同期更新コンポーネントプロパティは非表示です。テストでは、変更検出の別のサイクルをトリガーするために手動でfixture.detectChanges()を呼び出す必要があります。

Promiseを設定する際にPromiseを解決する方法が原因で、それが同期更新として処理されており、_Auto Detection Service_がそれに応答しないと思います。

_component.values = Promise.resolve(['A', 'B']);
_

自動変更検出


与えられたさまざまな例を調べると、AutoDetectなしでfixture.detectChanges()を2回呼び出す必要がある理由がわかります。最初に_Delayed change detection_モデルでngOnInitをトリガーします... 2回目に呼び出すと、ビューが更新されます。

これは、以下のコード例のfixture.detectChanges()の右側のコメントに基づいて確認できます

_it('should show quote after getQuote (fakeAsync)', fakeAsync(() => {
  fixture.detectChanges(); // ngOnInit()
  expect(quoteEl.textContent).toBe('...', 'should show placeholder');

  tick(); // flush the observable to get the quote
  fixture.detectChanges(); // update view

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

非同期テストの例


要約:_Automatic change detection_を利用しない場合、fixture.detectChanges()を呼び出すと、_Delayed Change Detection_モデルが「ステップ」されます... Angularがデータバインディングを開始し、ライフサイクルフックを呼び出す前に、コンポーネントの状態を検査して変更する機会を与える。

また、提供されたリンクからの次のコメントにも注意してください。

テストフィクスチャが変更の検出を行うか行わないかを考えるのではなく、このガイドのサンプルは常に明示的にdetectChanges()を呼び出します。厳密に必要な場合よりも頻繁にdetectChanges()を呼び出しても害はありません。


2番目の例Stackblitz

53行目のdetectChanges()をコメントアウトすると同じ_console.log_出力が生成されることを示す2番目の例のstackblitz。 detectChanges()の前にwhenStable()を2回呼び出す必要はありません。 detectChanges()を3回呼び出していますが、whenStable()の前の2番目の呼び出しには影響がありません。新しい例では、本当にdetectChanges()の2つから何かを得ているだけです。

厳密に必要な場合よりも頻繁にdetectChanges()を呼び出しても害はありません。

https://stackblitz.com/edit/directive-testing-cwyzrq?embed=1&file=app/app.component.spec.ts


UPDATE:2番目の例(2019/03/21に再度更新)

以下のバリアントからのさまざまな出力を確認できるように、stackblitzを提供します。

  • awaitfixture.whenStable();
  • フィクスチャ.whenStable()。then(()=> {})
  • awaitfixture.whenStable()。then(()=> {})

Stackblitz

https://stackblitz.com/edit/directive-testing-b3p5kg?embed=1&file=app/app.component.spec.ts

11
Marshal

私の意見では、2番目のテストは間違っているようです。次のパターンに従って記述してください。

_component.values = Promise.resolve(['A', 'B']);
fixture.whenStable().then(() => {
  fixture.detectChanges();       
  expect(Array.from(element.querySelectorAll('li')).map(elem => elem.textContent)).toEqual(['A', 'B']);
});
_

ご覧ください: 安定使用時

whenStable()内でdetectChangesを次のように呼び出す必要があります

Fixture.whenStable()は、JavaScriptエンジンのタスクキューが空になると解決するpromiseを返します。

0
Mac_W