Angular: Тестване на асинхронни неща в фалшиватаAsync зона VS. осигуряване на персонализирани планиращи програми

Много пъти са ми задавани въпроси относно „фалшивата зона“ и как да я използвам. Ето защо реших да напиша тази статия, за да споделя моите наблюдения, когато става въпрос за фини тестове „fakeAsync“.

Зоната е решаваща част от ъгловата екосистема. Може би бихте прочели, че самата зона е просто един вид „контекст на изпълнение“. Всъщност, Angular monkeypatches на глобалните функции като setTimeout или setInterval, за да прихваща функции, изпълнявани след известно закъснение (setTimeout) или периодично (setInterval).

Важно е да се спомене, че тази статия няма да покаже как да се справяте с хакове от setTimeout. Тъй като Angular използва широко RxJs това, което разчита на естествените функции за синхронизация (може да се изненадате, но това е вярно), той използва зоната като сложен, но мощен инструмент за запис на всички асинхронни действия, които могат да повлияят на състоянието на приложението. Ъгловите ги прихващат, за да знаят дали все още има някаква работа в опашката. Тя източва опашката в зависимост от времето. Най-вероятно източените задачи променят стойностите на компонентните променливи. В резултат на това шаблонът се рендерира.

Сега всички неща за асинхронизацията не са това, за което трябва да се тревожим. Просто е хубаво да разберете какво се случва под капака, защото помага да се напишат ефективни тестове на единица. Нещо повече, разработката с тестови програми оказва огромно влияние върху изходния код („Произходът на TDD беше желание да се направи силна автоматична регресионна проверка, която подкрепяше еволюционния дизайн. По пътя на нейните практикуващи откриха, че тестовете за писане първо направиха значително подобрение в процеса на проектиране. „Мартин Фаулър, https://martinfowler.com/articles/mocksArentStubs.html, 09/2017).

В резултат на всички тези усилия можем да изместим времето, което трябва да тестваме за състоянието в определен момент.

fakeAsync / очертайте отметки

Ъгловите документи посочват, че fakeAsync (https://angular.io/guide/testing#fake-async) предлага по-линейно кодиране, защото се отървава от обещания като .whenStable (). Тогава (…).

Кодът в блока fakeAsync изглежда така:

кърлежи (100); // изчакайте първата задача да бъде свършена
fixture.detectChanges (); // актуализиране на изгледа с цитат
кърлежи (); // изчакайте да свършите втората задача
fixture.detectChanges (); // актуализиране на изгледа с цитат

Следните фрагменти дават някаква представа за начина, по който работи fakeAsync.

setTimeout / setInterval се използват тук, защото те ясно показват кога функциите се изпълняват в зоната на fakeAsync. Може да очаквате, че тази функция „тя“ трябва да знае кога е направен тестът (в Жасмин, подреден по аргумент, изпълнен: Функция), но този път разчитаме на придружителя fakeAsync, а не да използваме какъвто и да е вид обратни обаждания:

it ("източва задачата на зоната по задача", fakeAsync (() => {
        setTimeout (() => {
            нека i = 0;
            const handle = setInterval (() => {
                ако (i ++ === 5) {
                    clearInterval (дръжката);
                }
            }, 1000);
        }, 10000);
}));

Той се оплаква силно, защото все още има някои „таймери“ (= setTimeouts) на опашката:

Грешка: 1 таймер (и) все още е на опашката.

Очевидно е, че трябва да изместим времето, за да изпълним определената функция. Добавяме параметризирания „тик“ с 10 секунди:

кърлежи (10000);

Хю? Грешката става по-объркваща. Сега тестът се проваля поради включеното „периодични таймери“ (= setIntervals):

Грешка: 1 периодични таймера (и) все още са на опашката.

Тъй като ние въведохме функция, която трябва да се изпълнява всяка секунда, ние също трябва да изместим времето, като използваме отметката отново. Функцията се прекратява след 5 секунди. Ето защо трябва да добавим още 5 секунди:

кърлежи (15000);

Сега тестът преминава. Струва си да се каже, че зоната разпознава задачи, работещи паралелно. Просто удължете изчакваната функция чрез друго setInterval повикване.

it ("източва задачата на зоната по задача", fakeAsync (() => {
    setTimeout (() => {
        нека i = 0;
        const handle = setInterval (() => {
            ако (++ i === 5) {
                clearInterval (дръжката);
            }
        }, 1000);
        нека j = 0;
        const handle2 = setInterval (() => {
            ако (++ j === 3) {
                clearInterval (handle2);
            }
        }, 1000);
    }, 10000);
    кърлежи (15000);
}));

Тестът все още преминава, тъй като и двата тези setIntervals са стартирани в същия момент. И двете се правят, когато са минали 15 секунди:

fakeAsync / отметка в действие

Сега знаем как работят нещата от фалшив асинхрон / тик. Нека се използва за някои смислени неща.

Нека разработим поле, подобно на предложенията, което отговаря на тези изисквания:

  • тя хваща резултата от някакъв API (услуга)
  • той заглушава въвеждането на потребителя, за да изчака последната дума за търсене (намалява броя на заявките); DEBOUNCING_VALUE = 300
  • той показва резултата в потребителския интерфейс и излъчва съответното съобщение
  • тестът на единицата зачита асинхронния характер на кода и тества правилното поведение на полето, подобно на предложенията, по отношение на изминалото време

Завършваме с тези сценарии за тестване:

description ('при търсене', () => {
    it ("изчиства предишния резултат", fakeAsync (() => {
    }));
    it ('излъчва стартовия сигнал', fakeAsync (() => {
    }));
    it („заглушава възможните посещения на API към 1 заявка на DEBOUNCING_VALUE милисекунди“, fakeAsync (() => {
    }));
});
description ('при успех', () => {
    it („извиква API на google“, fakeAsync (() => {
    }));
    it („излъчва сигнала за успех с брой съвпадения“, fakeAsync (() => {
    }));
    it („показва заглавията в полето за предложения“, fakeAsync (() => {
    }));
});
description ('при грешка', () => {
    it ('излъчва сигнала за грешка ", fakeAsync (() => {
    }));
});

В „при търсене“ не чакаме резултата от търсенето. Когато потребителят предостави вход (например „Lon“), предишните опции трябва да бъдат изчистени. Очакваме опциите да са празни. Освен това потребителското въвеждане трябва да бъде заглушено, да речем със стойност от 300 милисекунди. По отношение на зоната, на опашката се избутват 300-милиметрови табла.

Обърнете внимание, че пропускам някои детайли за краткост:

  • настройката на теста е почти същата като тази в Angular документи
  • инстанцията apiService се инжектира чрез fixture.debugElement.injector (…)
  • SpecUtils задейства свързани с потребителя събития като въвеждане и фокус
предиEach (() => {
    spyOn (apiService, 'заявка'). и.returnValue (наблюдаем.of (queryResult));
});
fit ('изчиства предишния резултат', fakeAsync (() => {
    comp.options = ['непразна'];
    SpecUtils.focusAndInput ('Lon', приспособление, 'input');
    кърлежи (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    очаквам (comp.options.length) .toBe (0, `беше [$ {comp.options.join (',')}] ');
}));

Кодът на компонента, който се опитва да удовлетвори теста:

ngOnInit () {
    this.control.valueChanges.debounceTime (300) .отпишете се (стойност => {
        this.options = [];
        this.suggest (стойност);
    });
}
predlaga (q: низ) {
    this.googleBooksAPI.query (q) .subscribe (result => {
// ...
    }, () => {
// ...
    });
}

Нека преминем през кода стъпка по стъпка:

Шпионираме метода на заявката apiService, който ще извикаме в компонента. Променливата queryResult съдържа някои макетни данни като „Hamlet“, „Macbeth“ и „King Lear“. В началото очакваме опциите да са празни, но както може би сте забелязали цялата опашка за фалшиви асинхронизации се източва с отметка (DEBOUNCING_VALUE) и следователно компонентът съдържа и крайния резултат от писанията на Шекспир:

Очаква се 3 да е 0, „беше [Hamlet, Macbeth, King Lear]“.

Нуждаем се от забавяне на заявката за заявка за услуга, за да подражаваме на асинхронен период от време, изразходван от повикването на API. Нека добавим 5 секунди закъснение (REQUEST_DELAY = 5000) и отметнете (5000).

предиEach (() => {
    spyOn (apiService, 'заявка'). и.returnValue (наблюдаем.of (queryResult) .delay (1000));
});

fit ('изчиства предишния резултат', fakeAsync (() => {
    comp.options = ['непразна'];
    SpecUtils.focusAndInput ('Lon', приспособление, 'input');
    кърлежи (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    очаквам (comp.options.length) .toBe (0, `беше [$ {comp.options.join (',')}] ');
    кърлежи (REQUEST_DELAY);
}));

Според мен този пример трябва да работи, но Zone.js твърди, че все още има някаква работа в опашката:

Грешка: 1 периодични таймера (и) все още са на опашката.

В този момент трябва да отидем по-дълбоко, за да видим онези функции, за които подозираме, че са заседнали в зоната. Задаването на някои точки на прекъсване е начинът да се постигне:

отстраняване на грешки в фалшивата зона за синхронизация

След това издайте това в командния ред

_fakeAsyncTestZoneSpec._scheduler._schedulerQueue [0] .args [0] [0]

или проучете съдържанието на зоната така:

хммм, методът на промиване на AsyncScheduler все още е на опашката ... защо?

Името на функцията, която се задейства, е методът на промиване на AsyncScheduler.

публичен флъш (действие: AsyncAction ): void {
  const {действия} = това;
  ако (това.активно) {
    actions.push (действие);
    се върне;
  }
  нека грешка: всяка;
  this.active = вярно;
  направете {
    ако (грешка = action.execute (action.state, action.delay)) {
      прекъсване;
    }
  } while (action = Actions.shift ()); // изчерпайте опашката за планиране
  this.active = невярно;
  ако (грешка) {
    докато (action = Actions.shift ()) {
      action.unsubscribe ();
    }
    грешка при хвърляне;
  }
}

Сега може да се чудите какво не е наред със самия изходен код или зона.

Проблемът е, че зоната и нашите кърлежи не са в синхрон.

Самата зона има текущото време (2017), докато тикът иска да обработи действието, насрочено за 01.01.1970 г. + 300 милисета + 5 секунди.

Стойността на асинхронния планировчик потвърждава, че:

import {async as AsyncScheduler} от 'rxjs / planer / async';
// поставете това някъде вътре в „it“
console.info (AsyncScheduler.now ());
// → 1503235213879

AsyncZoneTimeInSyncKeeper на помощ

Един от възможните поправки за това е да имате помощна програма за поддържане на синхронизация като тази:

експорт клас AsyncZoneTimeInSyncKeeper {
    време = 0;
    конструктор () {
        spyOn (AsyncScheduler, „сега“). и.callFake (() => {
            / * tslint: изключване-следващ ред * /
            console.info ('време', това време);
            върнете this.time;
        });
    }
    отметка (време ?: число) {
        if (typeof time! == 'undefined') {
            this.time + = време;
            кърлежи (this.time);
        } else {
            кърлежи ();
        }
    }
}

Той следи текущото време, което се връща от сега () всеки път, когато се извика асинхронизаторът. Това работи, защото функцията tick () използва същото текущо време. И двете, и графикът, и зоната, споделят едно и също време.

Препоръчвам да инсталирате timeInSyncKeeper във фазата преди

description ('при търсене', () => {
    нека времеInSyncKeeper;
    предиEach (() => {
        timeInSyncKeeper = нов AsyncZoneTimeInSyncKeeper ();
    });
});

Сега, нека да разгледаме използването на пазителя на синхронизирането на времето. Имайте предвид, че трябва да се справим с този проблем с времето, тъй като текстовото поле се деактивира и заявката отнема известно време.

description („при търсене“, () => {
    нека времеInSyncKeeper;
    предиEach (() => {
        timeInSyncKeeper = нов AsyncZoneTimeInSyncKeeper ();
        spyOn (apiService, 'заявка'). и.returnValue (наблюдаем.of (queryResult) .delay (REQUEST_DELAY));
    });
    it ("изчиства предишния резултат", fakeAsync (() => {
        comp.options = ['непразна'];
        SpecUtils.focusAndInput ('Lon', приспособление, 'input');
        timeInSyncKeeper.tick (DEBOUNCING_VALUE);
        fixture.detectChanges ();
        очаквам (comp.options.length) .toBe (0, `беше [$ {comp.options.join (',')}] ');
        timeInSyncKeeper.tick (REQUEST_DELAY);
    }));
    // ...
});

Нека преминем през този пример ред по ред:

  1. инстанция на инстанцията на синхронизиращия пазител
timeInSyncKeeper = нов AsyncZoneTimeInSyncKeeper ();

2. оставете отговор на метода apiService.query с резултат queryResult след преминаване на REQUEST_DELAY. Да кажем, че методът на заявката е бавен и отговаря след REQUEST_DELAY = 5000 милисекунди.

spyOn (apiService, 'заявка'). и.returnValue (наблюдаем.of (queryResult) .delay (REQUEST_DELAY));

3. Преструвайте се, че има опция „не празно“ в полето за предложения

comp.options = ['непразна'];

4. Отидете до полето „input“ в родния елемент на устройството и поставете стойността „Lon“. Това симулира взаимодействието на потребителя с полето за въвеждане.

SpecUtils.focusAndInput ('Lon', приспособление, 'input');

5. оставете да премине периода от време DEBOUNCING_VALUE във фалшивата асинхронна зона (DEBOUNCING_VALUE = 300 милисекунди).

timeInSyncKeeper.tick (DEBOUNCING_VALUE);

6. Открийте промените и рендерирайте HTML шаблона.

fixture.detectChanges ();

7. Масивът с опции е празен!

очаквам (comp.options.length) .toBe (0, `беше [$ {comp.options.join (',')}] ');

Това означава, че наблюдаваните стойности Промените, използвани в компонентите, са били управлявани в точното време. Обърнете внимание, че изпълнената функция debounceTime-d

стойност => {
    this.options = [];
    this.onEvent.emit ({сигнал: SuggestSignal.start});
    this.suggest (стойност);
}

избута друга задача в опашката, като се обади на метода предполага:

predlaga (q: низ) {
    ако (! q) {
        се върне;
    }
    this.googleBooksAPI.query (q) .subscribe (result => {
        ако (резултат) {
            this.options = result.items.map (item => item.volumeInfo);
            this.onEvent.emit ({сигнал: SuggestSignal.success, totalItems: result.totalItems});
        } else {
            this.onEvent.emit ({сигнал: SuggestSignal.success, totalItems: 0});
        }
    }, () => {
        this.onEvent.emit ({сигнал: SuggestSignal.error});
    });
}

Просто припомнете шпионина на метода на заявка за google books API, който отговаря след 5 секунди.

8. И накрая, трябва да отбележим отново за REQUEST_DELAY = 5000 милисекунди, за да заминем опашката на зоната. Наблюдаваното, за което се абонираме в метода на предложението, трябва да завърши REQUEST_DELAY = 5000.

timeInSyncKeeper.tick (REQUEST_DELAY);

fakeAsync ...? Защо? Има планиращи!

Експертите на ReactiveX може да твърдят, че бихме могли да използваме тестови планиращи програми, за да направим наблюдаемите тестируеми. Възможно е за ъглови приложения, но има някои недостатъци:

  • тя изисква да се запознаете с вътрешната структура на наблюденията, операторите,…
  • Какво става, ако имате някои грозни setTimeout решения в приложението си? Те не се обработват от планиращите.
  • най-важният от тях: Сигурен съм, че не искате да използвате планиращи програми в цялото си приложение. Не искате да смесвате производствения код с тестовете на вашата единица. Не искате да правите нещо подобно:
const testScheduler;
ако (Environment.test) {
    testScheduler = нов YourTestScheduler ();
}
нека се наблюдава;
ако (testScheduler) {
    observable = Observable.of ('value') забавяне (1000, testScheduler)
} else {
    наблюдателен = Observable.of ('value'). забавяне (1000);
}

Това не е жизнеспособно решение. Според мен единственото възможно решение е да „инжектирате“ тестовия планировчик, като предоставите вид „прокси“ за истинските Rxjs методи. Друго нещо, което трябва да се вземе предвид, е, че по-важните методи могат да повлияят негативно на останалите тестове на единицата. Ето защо ще използваме шпионите на Жасмин. Шпионите се изчистват след всяко него.

Функцията monkeypatchScheduler обвива оригиналната Rxjs реализация, като използва шпионин. Шпионинът взема аргументите на метода и при необходимост добавя testScheduler.

import {IScheduler} от 'rxjs / Scheduler';
import {Observable} от 'rxjs / Observable';
декларира var spyOn: Функция;
функция за експортиране monkeypatchScheduler (планировчик: IScheduler) {
    нека observableMethods = ['concat', 'defer', 'empty', 'forkJoin', 'if', 'interval', 'merge', 'of', 'range', 'хвърлям',
        "Цип"];
    нека operatorMethods = ['буфер', 'concat', 'забавяне', 'отчетливо', 'do', 'всеки', 'last', 'merge', 'max', 'take',
        'timeInterval', 'lift', 'debounceTime'];
    нека injectFn = функция (база: всяка, методи: string []) {
        method.forEach (метод => {
            const orig = база [метод];
            ако (typeof orig === 'функция') {
                spyOn (база, метод) .and.callFake (функция () {
                    нека args = Array.prototype.slice.call (аргументи);
                    ако (args [args.length - 1] && typeof args [args.length - 1] .now === 'функция') {
                        args [args.length - 1] = планировчик;
                    } else {
                        args.push (Scheduler);
                    }
                    return orig.apply (това, args);
                });
            }
        });
    };
    injectFn (Наблюдаем, наблюдаемМетоди);
    injectFn (Observable.prototype, operatorMethods);
}

Отсега нататък testScheduler ще изпълни цялата работа вътре в Rxjs. Той не използва setTimeout / setInterval или какъвто и да е вид асинхронни неща. Вече няма нужда от fakeAsync.

Сега имаме нужда от тестов екземпляр за планиране, който искаме да предадем на monkeypatchScheduler.

Той се държи много като по подразбиране TestScheduler, но осигурява метод за обратно извикване onAction. По този начин ние знаем кое действие е било извършено след кой период от време.

експортният клас SpyingTestScheduler разширява VirtualTimeScheduler {
    spyFn: (actionName: низ, забавяне: число, грешка ?: някакъв) => void;
    конструктор () {
        супер (VirtualAction, defaultMaxFrame);
    }
    onAction (spyFn: (actionName: string, забавяне: номер, грешка ?: който и да е) => void) {
        this.spyFn = spyFn;
    }
    flush () {
        const {действия, maxFrames} = това;
        нека грешка: всяко, действие: AsyncAction ;
        докато ((action = Actions.shift ()) && (this.frame = action.delay) <= maxFrames) {
            нека stateName = this.detectStateName (действие);
            нека забави = action.delay;
            ако (грешка = action.execute (action.state, action.delay)) {
                ако (this.spyFn) {
                    this.spyFn (stateName, забавяне, грешка);
                }
                прекъсване;
            } else {
                ако (this.spyFn) {
                    this.spyFn (stateName, забавяне);
                }
            }
        }
        ако (грешка) {
            докато (action = Actions.shift ()) {
                action.unsubscribe ();
            }
            грешка при хвърляне;
        }
    }
    private detectStateName (действие: AsyncAction ): низ {
        const c = Object.getPrototypeOf (action.state) .constructor;
        const argsPos = c.toString (). indexOf ('(');
        ако (argsPos! == -1) {
            върнете c.toString (). подреда (9, argsPos);
        }
        връща нула;
    }
}

И накрая, нека да разгледаме използването. Примерът е същият тест на единиците, както беше използван преди (той („изчиства предишния резултат“) с малка разлика, че ще използваме тестовия планировчик вместо fakeAsync / отметка.

нека testScheduler;
предиEach (() => {
    testScheduler = нов SpyingTestScheduler ();
    testScheduler.maxFrames = 1000000;
    monkeypatchScheduler (testScheduler);
    fixture.detectChanges ();
});
предиEach (() => {
    spyOn (apiService, 'заявка'). и.callFake (() => {
        върнете Observable.of (queryResult) .delay (REQUEST_DELAY);
    });
});
it ("изчиства предишния резултат", (done: Function) => {
    comp.options = ['непразна'];
    testScheduler.onAction ((actionName: string, забавяне: число, грешка ?: който и да е) => {
        ако (actionName === 'DebounceTimeSubscriber' && забавяне === DEBOUNCING_VALUE) {
            очаквам (comp.options.length) .toBe (0, `беше [$ {comp.options.join (',')}] ');
            Свършен();
        }
    });
    SpecUtils.focusAndInput ('Londo', приспособление, 'input');
    fixture.detectChanges ();
    testScheduler.flush ();
});

Тестовият планировчик се създава и се монтира (!) В първия beforeEach. Във втория предиEach шпионираме apiService.query, за да обслужваме резултата queryResult след REQUEST_DELAY = 5000 милисекунди.

Сега, нека преминем през него ред по ред:

  1. На първо място, обърнете внимание, че ние декларираме изпълнена функция, която ни е необходима във връзка с функцията за обратно действие на извикванията на планиращия тест. Това означава, че трябва да кажем на Жасмин, че тестът се прави самостоятелно.
it ("изчиства предишния резултат", (done: Function) => {

2. Отново се преструваме на някои опции, присъстващи в компонента.

comp.options = ['непразна'];

3. Това изисква малко обяснение, защото на пръв поглед изглежда малко тромаво. Искаме да изчакаме действие, наречено „DebounceTimeSubscriber“ със закъснение от DEBOUNCING_VALUE = 300 милисекунди. Когато това се случи, ние искаме да проверим дали options.length е 0. След това тестът е завършен и ние извикваме done ().

testScheduler.onAction ((actionName: string, забавяне: число, грешка ?: който и да е) => {
    ако (actionName === 'DebounceTimeSubscriber' && забавяне === DEBOUNCING_VALUE) {
      очаквам (comp.options.length) .toBe (0, `беше [$ {comp.options.join (',')}] ');
      Свършен();
    }
});

Виждате, че използването на тестови планиращи програми изисква някои специални познания за вътрешните изпълнения на Rxjs. Разбира се, зависи какъв тестов планировчик използвате, но дори и да внедрите мощен планировчик самостоятелно, ще трябва да разберете планиращите програми и да изложите някои стойности на време за изпълнение за гъвкавост (което, отново, може да не се обяснява само).

4. Отново потребителят въвежда стойността „Londo“.

SpecUtils.focusAndInput ('Londo', приспособление, 'input');

5. Отново открийте промените и рендерирайте шаблона.

fixture.detectChanges ();

6. Накрая изпълняваме всички действия, поставени в опашката на планиращия.

testScheduler.flush ();

резюме

Собствените уреди за тестване на ъглите са за предпочитане пред тези, направени самостоятелно ... стига да работят. В някои случаи двойката fakeAsync / отметка не работи, но няма причина да се отчайвате и да пропускате единичните тестове. В тези случаи автоматичната програма за синхронизиране (тук също известна като AsyncZoneTimeInSyncKeeper) или персонализиран тестов график (тук също познат като SpyingTestScheduler) е пътят.

Програмен код