Зачем мне дважды вызывать detectChanges/whenStable?

Первый пример

Я получил следующий тест:

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. Есть два теста, один из которых не пройден, а другой пройден. Единственное различие между этими тестами состоит в том, что тест, который прошел, вызывает fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); дважды.

ОБНОВЛЕНИЕ: Второй пример (обновлено снова 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()

Журналы второго теста:

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

Я как бы ожидал, что тест проходит в угловой зоне, но это не так. Кажется, проблема в том, что

Чтобы избежать неожиданностей, функции, передаваемые в then(), никогда не будут вызываться синхронно, даже с уже решенным обещанием. (Источник)

Во втором примере я спровоцировал проблему, несколько раз вызывая .then(x => x), что не более чем снова помещает прогресс в цикл событий браузера и, таким образом, задерживает результат. Насколько я понимаю, вызов await fixture.whenStable() должен в основном сказать: "подождите, пока эта очередь не станет пустой". Как мы видим, это действительно работает, если я выполняю код в ngZone явно. Однако это не по умолчанию, и я не могу найти где-нибудь в руководстве, что предполагается, что я пишу свои тесты таким образом, так что это чувствует себя неловко.

Что на самом деле await fixture.whenStable() во втором тесте? Исходный код показывает, что в этом случае fixture.whenStable() просто return Promise.resolve(false); , Поэтому я попытался заменить await fixture.whenStable() на await Promise.resolve() и это действительно имеет тот же эффект: это приводит к приостановке теста и его valuePromise.then(...) очереди событий, и, таким образом, обратный вызов передается в valuePromise.then(...) на самом деле выполняется, если я просто достаточно часто называю await любого обещания.

Почему мне нужно вызвать await fixture.whenStable(); многократно? Я использую это неправильно? Это намеренное поведение? Есть ли "официальная" документация о том, как она предназначена для работы/как с этим бороться?

Ответы

Ответ 1

Я считаю, что вы испытываете Delayed change detection.

Обнаружение отложенных изменений является намеренным и полезным. Это дает тестировщику возможность проверять и изменять состояние компонента до того, как Angular инициирует привязку данных и вызывает ловушки жизненного цикла.

detectChanges()


Реализация Automatic Change Detection позволяет вызывать fixture.detectChanges() только один раз в обоих тестах.

 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 важен, и поэтому вашим тестам по-прежнему нужно вызывать fixture.detectChanges(), даже с AutoDetect.

Второй и третий тест выявляют важное ограничение. Среда тестирования Angular не знает, что тест изменил название компонента. Служба ComponentFixtureAutoDetect реагирует на асинхронные действия, такие как разрешение обещаний, таймеры и события DOM. Но прямое синхронное обновление свойства компонента невидимо. Тест должен вызвать fixture.detectChanges() вручную, чтобы запустить другой цикл обнаружения изменений.

Из-за того, как вы решаете Обещание в том виде, в котором вы его настраиваете, я подозреваю, что оно рассматривается как синхронное обновление, и Auto Detection Service автоопределения не будет отвечать на него.

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

Автоматическое обнаружение изменений


Изучение различных приведенных примеров дает представление о том, почему вам нужно дважды вызывать fixture.detectChanges() без AutoDetect. При первом ngOnInit в модели Delayed change detection... вызов его во второй раз обновляет представление.

Это можно увидеть по комментариям справа от 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');
}));

Больше асинхронных тестов Пример


fixture.detectChanges() : если не использовать Automatic change detection, вызов fixture.detectChanges() будет "шагать" по модели Delayed Change Detection... предоставляя вам возможность проверять и изменять состояние компонента до того, как Angular инициирует привязку данных и вызывает жизненный цикл крючки.

Также обратите внимание на следующий комментарий по предоставленным ссылкам:

Вместо того, чтобы удивляться, когда тестовое устройство выполнит или не выполнит обнаружение изменений, примеры в этом руководстве всегда явно вызывают функцию receiveChanges(). Вызывать функцию receiveChanges() чаще всего не вредно, чем это строго необходимо.


Второй пример Stackblitz

Второй пример stackblitz, показывающий, что комментирование строки 53 detectChanges() приводит к тому же выводу console.log. detectChanges() дважды до того, whenStable() не является необходимым. Вы вызываете detectChanges() три раза, но второй вызов whenStable() не оказывает никакого влияния. Вы действительно получаете что-то только от двух detectChanges() в новом примере.

Вызывать функцию receiveChanges() чаще всего не вредно, чем это строго необходимо.

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


ОБНОВЛЕНИЕ: Второй пример (обновлено снова 2019/03/21)

Предоставление stackblitz для демонстрации различных результатов из следующих вариантов для вашего обзора.

  • ожидание fixture.whenStable();
  • fixture.whenStable(). Затем (() => {})
  • ожидайте fixture.whenStable(). then (() => {})

Stackblitz

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

Ответ 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']);
});

Пожалуйста, смотрите: когда стабильное использование

Вы должны вызывать detectChanges рамках whenStable() как

Fixture.whenStable() возвращает обещание, которое разрешается, когда очередь задач движка JavaScript становится пустой.