Úhlová: Testování asynchronních látek ve zóně fakedAsync VS. poskytování vlastních plánovačů

Mnohokrát mi byly položeny otázky o „falešné zóně“ a o tom, jak ji používat. Proto jsem se rozhodl napsat tento článek, abych se podělil o své postřehy, pokud jde o jemnozrnné testy „fakeAsync“.

Zóna je rozhodující součástí úhlového ekosystému. Dalo by se přečíst, že zóna sama o sobě je jen druh „kontextu provádění“. Ve skutečnosti Angular monkeypatches globální funkce, jako je setTimeout nebo setInterval, aby zachytily funkce prováděné po určitém zpoždění (setTimeout) nebo periodicky (setInterval).

Je důležité zmínit, že tento článek neukáže, jak se vypořádat s hackery setTimeout. Protože Angular intenzivně využívá RxJ, které se spoléhají na nativní funkce časování (můžete být překvapeni, ale je to pravda), používá zónu jako komplexní, ale výkonný nástroj pro zaznamenávání všech asynchronních akcí, které by mohly ovlivnit stav aplikace. Úhlové je zachytí, aby se zjistilo, zda ve frontě stále existuje nějaká práce. Vypouští frontu v závislosti na čase. Nejpravděpodobnější je, že vyčerpané úkoly mění hodnoty proměnných komponenty. Výsledkem je opětné vykreslení šablony.

Nyní, async věci nejsou to, co musíme starat. Je prostě příjemné pochopit, co se děje pod kapotou, protože pomáhá psát efektivní testy jednotek. Vývoj řízený testem má navíc obrovský dopad na zdrojový kód („TDD počátky byly touhou po silném automatickém regresním testování, které podporovalo evoluční design. Během jeho praktiků objevilo, že psaní testů nejprve významně zlepšilo proces návrhu. „Martin Fowler, https://martinfowler.com/articles/mocksArentStubs.html, 09/2017).

V důsledku všech těchto snah můžeme posunout čas, protože potřebujeme vyzkoušet stav v určitém časovém bodě.

fakeAsync / tick outline

Úhlové dokumenty uvádějí, že fakeAsync (https://angular.io/guide/testing#fake-async) poskytuje lineárnější kódování, protože se zbavuje příslibů, jako je .whenStable ()., Poté (...).

Kód uvnitř bloku fakeAsync vypadá takto:

klíště (100); // počkejte na dokončení prvního úkolu
fixture.detectChanges (); // aktualizace zobrazení s citací
klíště(); // počkejte na dokončení druhého úkolu
fixture.detectChanges (); // aktualizace zobrazení s citací

Následující úryvky poskytují několik pohledů na to, jak funguje fakeAsync.

setTimeout / setInterval se zde používají, protože jasně ukazují, kdy jsou funkce provedeny v zóně fakeAsync. Dalo by se očekávat, že tato „it“ funkce musí vědět, kdy je test proveden (v Jasmine uspořádané argumentem done: Function), ale tentokrát se spoléháme spíše na fakeAsync společníka, než na jakýkoli druh zpětného volání:

it ('odčerpá úlohu zóny podle úkolu', fakeAsync (() => {
        setTimeout (() => {
            nechť i = 0;
            const handle = setInterval (() => {
                if (i ++ === 5) {
                    clearInterval (handle);
                }
            }, 1000);
        }, 10000);
}));

Hlasitě si stěžuje, protože ve frontě jsou stále nějaké „časovače“ (= setTimeouts):

Chyba: 1 časovač (y) stále ve frontě.

Je zřejmé, že musíme udělat čas, abychom dosáhli funkce timeouted. Připojíme parametrizovaný „tick“ za 10 sekund:

klíště (10 000);

Hugh? Chyba je matoucí. Nyní se test nezdaří z důvodu „periodických časovačů“ (= setIntervals):

Chyba: 1 periodický časovač (y) stále ve frontě.

Protože jsme si vymysleli funkci, která musí být vykonávána každou sekundu, musíme také přesunout čas opětovným použitím klíště. Funkce se ukončí po 5 sekundách. Proto musíme přidat dalších 5 sekund:

klíště (15000);

Nyní test prochází. Stojí za zmínku, že zóna rozpoznává paralelní úlohy. Stačí rozšířit časově omezenou funkci o další volání setInterval.

it ('odčerpá úlohu zóny podle úkolu', fakeAsync (() => {
    setTimeout (() => {
        nechť i = 0;
        const handle = setInterval (() => {
            if (++ i === 5) {
                clearInterval (handle);
            }
        }, 1000);
        nechť j = 0;
        const handle2 = setInterval (() => {
            if (++ j === 3) {
                clearInterval (handle2);
            }
        }, 1000);
    }, 10000);
    klíště (15000);
}));

Test stále probíhá, protože oba tyto setIntervaly byly spuštěny ve stejnou chvíli. Obě se provádějí po uplynutí 15 sekund:

fakeAsync / tick in action

Nyní víme, jak funguje fakeAsync / tick. Nechte ji použít pro některé smysluplné věci.

Pojďme vyvinout návrhové pole, které splňuje tyto požadavky:

  • chytí výsledek z nějakého API (služby)
  • škrtí vstup uživatele, aby čekal na konečný vyhledávací dotaz (snižuje se počet požadavků); DEBOUNCING_VALUE = 300
  • zobrazuje výsledek v uživatelském rozhraní a vysílá příslušnou zprávu
  • test jednotky respektuje asynchronní povahu kódu a testuje správné chování navrhovaného pole z hlediska uplynulého času

Skončíme s těmito scénáři testování:

description ('on search', () => {
    to ('vymaže předchozí výsledek'), fakeAsync (() => {
    }));
    to ('vydává počáteční signál', fakeAsync (() => {
    }));
    it ('omezuje možné požadavky API na 1 žádost za DEBOUNCING_VALUE milisekundy', fakeAsync (() => {
    }));
});
description ('on success', () => {
    it ('volá Google API', fakeAsync (() => {
    }));
    to ('vydává signál úspěchu s počtem zápasů', fakeAsync (() => {
    }));
    it ('zobrazuje názvy v poli navrhnout', fakeAsync (() => {
    }));
});
description ('on error', () => {
    to ('vysílá chybový signál', fakeAsync (() => {
    }));
});

V „při vyhledávání“ neočekáváme výsledek vyhledávání. Pokud uživatel zadá vstup (např. „Lon“), musí být předchozí možnosti vymazány. Očekáváme, že možnosti budou prázdné. Navíc je třeba omezit vstup uživatele, řekněme hodnotou 300 milisekund. Z hlediska zóny je do fronty zatlačena mikrotisková maska ​​o velikosti 300 milisekund.

Všimněte si, že pro stručnost vynechám některé podrobnosti:

  • nastavení testu je téměř stejné jako v Angular docs
  • instance apiService je injektována prostřednictvím fixture.debugElement.injector (…)
  • SpecUtils spouští události související s uživatelem, jako je vstup a fokus
beforeEach (() => {
    spyOn (apiService, 'query'). a.returnValue (Observable.of (queryResult));
});
fit ('vymaže předchozí výsledek'), fakeAsync (() => {
    comp.options = ['non empty'];
    SpecUtils.focusAndInput ('Lon', příslušenství, 'input');
    tick (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    očekávat (comp.options.length) .toBe (0, `byl [$ {comp.options.join (',')}]`);
}));

Kód komponenty, který se pokouší vyhovět testu:

ngOnInit () {
    this.control.valueChanges.debounceTime (300) .subscribe (value => {
        this.options = [];
        this.suggest (hodnota);
    });
}
návrh (q: řetězec) {
    this.googleBooksAPI.query (q) .subscribe (result => {
// ...
    }, () => {
// ...
    });
}

Pojďme procházet kód krok za krokem:

Vyzkoušáme metodu dotazu apiService, kterou budeme v komponentě volat. Proměnná queryResult obsahuje několik falešných dat, jako jsou „Hamlet“, „Macbeth“ a „King Lear“. Na začátku očekáváme, že možnosti budou prázdné, ale jak jste si možná všimli, celá falešná syntéza se vyčerpá tickem (DEBOUNCING_VALUE), a proto komponenta obsahuje také konečný výsledek Shakespearových spisů:

Očekává se, že 3 bude 0, „byl [Hamlet, Macbeth, King Lear]“.

Abychom mohli emulovat asynchronní průchod času spotřebovaného voláním API, potřebujeme zpoždění pro požadavek na servisní dotaz. Přidáme 5sekundové zpoždění (REQUEST_DELAY = 5000) a zaškrtněte (5000).

beforeEach (() => {
    spyOn (apiService, 'query'). a.returnValue (pozorovatelné.of (queryResult) .delay (1000));
});

fit ('vymaže předchozí výsledek'), fakeAsync (() => {
    comp.options = ['non empty'];
    SpecUtils.focusAndInput ('Lon', příslušenství, 'input');
    tick (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    očekávat (comp.options.length) .toBe (0, `byl [$ {comp.options.join (',')}]`);
    tick (REQUEST_DELAY);
}));

Podle mého názoru by tento příklad měl fungovat, ale Zone.js tvrdí, že ve frontě je stále ještě nějaká práce:

Chyba: 1 periodický časovač (y) stále ve frontě.

V této chvíli musíme jít hlouběji, abychom viděli ty funkce, u kterých máme podezření, že se v oblasti uvíznou. Nastavení některých bodů přerušení je způsob, jak jít:

ladění zóny fakeAsync

Potom to vydejte na příkazovém řádku

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

nebo prozkoumejte obsah zóny takto:

hmmm, metoda Flush AsyncScheduler je stále ve frontě ... proč?

Název funkce, která má být vyvolána, je metoda vyprázdnění AsyncScheduler.

public flush (action: AsyncAction ): void {
  const {actions} = toto;
  if (this.active) {
    actions.push (action);
    vrátit se;
  }
  let error: any;
  this.active = true;
  dělat {
    if (error = action.execute (action.state, action.delay)) {
      přestávka;
    }
  } while (action = actions.shift ()); // vyčerpání fronty plánovače
  this.active = false;
  if (chyba) {
    while (action = actions.shift ()) {
      action.unsubscribe ();
    }
    hodová chyba;
  }
}

Nyní byste se mohli divit, co je špatného na zdrojovém kódu nebo samotné zóně.

Problém je v tom, že zóna a naše klíště nejsou synchronizovány.

Samotná zóna má aktuální čas (2017), zatímco tick chce zpracovat akci naplánovanou na 01.01.1970 + 300 milis + 5 sekund.

Hodnota asynchronního plánovače potvrzuje, že:

importovat {async jako AsyncScheduler} z 'rxjs / scheduler / async';
// umístit to někde uvnitř „it“
console.info (AsyncScheduler.now ());
// → 1503235213879

AsyncZoneTimeInSyncKeeper na záchranu

Jednou z možných oprav je mít obslužný program keep-in-sync, jako je tento:

exportovat třídu AsyncZoneTimeInSyncKeeper {
    čas = 0;
    konstruktor () {
        spyOn (AsyncScheduler, 'now'). a.callFake (() => {
            / * tslint: disable-next-line * /
            console.info ('time', this.time);
            vrátit this.time;
        });
    }
    tick (time ?: number) {
        if (typeof time! == 'undefined') {
            this.time + = čas;
            tick (this.time);
        } jinde {
            klíště();
        }
    }
}

Sleduje aktuální čas, který se vrátí pomocí now (), kdykoli je vyvolán asynchronní plánovač. Funguje to proto, že funkce tick () používá stejný aktuální čas. Plánovač i zóna sdílejí stejný čas.

Doporučuji instanci timeInSyncKeeper ve fázi před každou z nich:

description ('on search', () => {
    nechat timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = nový AsyncZoneTimeInSyncKeeper ();
    });
});

Nyní se podívejme na využití správce synchronizace času. Nezapomeňte, že musíme vyřešit tento problém s časováním, protože textové pole je odmítnuto a požadavek trvá nějakou dobu.

description (‘on search ', () => {
    nechat timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = nový AsyncZoneTimeInSyncKeeper ();
        spyOn (apiService, 'query'). a.returnValue (Observable.of (queryResult) .delay (REQUEST_DELAY));
    });
    to ('vymaže předchozí výsledek'), fakeAsync (() => {
        comp.options = ['non empty'];
        SpecUtils.focusAndInput ('Lon', příslušenství, 'input');
        timeInSyncKeeper.tick (DEBOUNCING_VALUE);
        fixture.detectChanges ();
        očekávat (comp.options.length) .toBe (0, `byl [$ {comp.options.join (',')}]`);
        timeInSyncKeeper.tick (REQUEST_DELAY);
    }));
    // ...
});

Pojďme projít tento příklad řádek po řádku:

  1. instanci instance správce synchronizace
timeInSyncKeeper = nový AsyncZoneTimeInSyncKeeper ();

2. nechte odpovědět na metodu apiService.query s výsledkem queryResult po uplynutí REQUEST_DELAY. Řekněme, že metoda dotazu je pomalá a reaguje po REQUEST_DELAY = 5000 milisekund.

spyOn (apiService, 'query'). a.returnValue (Observable.of (queryResult) .delay (REQUEST_DELAY));

3. Předstírejte, že v poli navrhování je možnost „neprázdná“

comp.options = ['non empty'];

4. Přejděte do pole „input“ v nativním prvku svítidla a vložte hodnotu „Lon“. To simuluje interakci uživatele se vstupním polem.

SpecUtils.focusAndInput ('Lon', příslušenství, 'input');

5. nechte projít časové období DEBOUNCING_VALUE ve falešné asynchronní zóně (DEBOUNCING_VALUE = 300 milisekund).

timeInSyncKeeper.tick (DEBOUNCING_VALUE);

6. Zjistit změny a znovu vykreslit šablonu HTML.

fixture.detectChanges ();

7. Pole voleb je nyní prázdné!

očekávat (comp.options.length) .toBe (0, `byl [$ {comp.options.join (',')}]`);

To znamená, že pozorovatelné hodnotyZměny použité v komponentách se podařilo spustit ve správný čas. Všimněte si, že vykonaná funkce debounceTime-d

value => {
    this.options = [];
    this.onEvent.emit ({signal: SuggestSignal.start});
    this.suggest (hodnota);
}

zavolal další úkol do fronty voláním metody navrhnout:

návrh (q: řetězec) {
    if (! q) {
        vrátit se;
    }
    this.googleBooksAPI.query (q) .subscribe (result => {
        if (result) {
            this.options = result.items.map (item => item.volumeInfo);
            this.onEvent.emit ({signal: SuggestSignal.success, totalItems: result.totalItems});
        } jinde {
            this.onEvent.emit ({signal: SuggestSignal.success, totalItems: 0});
        }
    }, () => {
        this.onEvent.emit ({signal: SuggestSignal.error});
    });
}

Stačí si vzpomenout na špionážní metodu dotazu API google books API, která reaguje po 5 sekundách.

8. Nakonec musíme znovu zaškrtnout REQUEST_DELAY = 5000 milisekund, aby se propláchla fronta zóny. Pozorovatelný, kterého se přihlásíme k odběru v metodě návrhu, potřebuje REQUEST_DELAY = 5000 k dokončení.

timeInSyncKeeper.tick (REQUEST_DELAY);

fakeAsync…? Proč? Existují plánovače!

Experti ReactiveX by mohli argumentovat, že bychom mohli použít testovací plánovače, aby byly pozorovatelné testovatelné. Je to možné pro úhlové aplikace, ale má to některé nevýhody:

  • vyžaduje, abyste se seznámili s vnitřní strukturou pozorovatelů, operátorů,…
  • co když v aplikaci máte nějaké ošklivé řešení setTimeout? Plánovači je nezvládli.
  • nejdůležitější: Určitě nechcete používat plánovače v celé své aplikaci. Nechcete smíchat výrobní kód s jednotkovými testy. Nechcete dělat něco takového:
const testScheduler;
if (environment.test) {
    testScheduler = new YourTestScheduler ();
}
nechat pozorovat;
if (testScheduler) {
    pozorovatelný = pozorovatelný („hodnota“). zpoždění (1000, testScheduler)
} jinde {
    pozorovatelný = pozorovatelný („hodnota“). zpoždění (1000);
}

Toto není proveditelné řešení. Podle mého názoru je jediným možným řešením „vstříknout“ plánovač testu poskytnutím jakési „proxy“ pro skutečné metody Rxjs. Další věc, kterou je třeba vzít v úvahu, je, že převažující metody by mohly negativně ovlivnit zbývající testy jednotek. Proto použijeme Jasmínovy špióny. Špehové se po každém z nich vyčistí.

Funkce monkeypatchScheduler zabalí původní implementaci Rxjs pomocí spy. Špionáž vezme argumenty metody a případně připojí testScheduler.

importovat {IScheduler} z 'rxjs / Scheduler';
importovat {Observable} z 'rxjs / Observable';
prohlásit var spyOn: Function;
exportní funkce monkeypatchScheduler (plánovač: IScheduler) {
    nechat observableMethods = ['concat', 'defer', 'empty', 'forkJoin', 'if', 'interval', 'merge', 'of', 'range', 'throw',
        'zip'];
    let operatorMethods = ['buffer', 'concat', 'delay', 'different', 'do', 'every', 'last', 'sloučit', 'max', 'take',
        'timeInterval', 'výtah', 'debounceTime'];
    let injFn = function (base: any, metody: string []) {
        methods.forEach (method => {
            const orig = base [metoda];
            if (typeof orig === 'function') {
                spyOn (base, method) .and.callFake (function () {
                    let args = Array.prototype.slice.call (argumenty);
                    if (args [args.length - 1] && typeof args [args.length - 1] .now === 'function') {
                        args [args.length - 1] = plánovač;
                    } jinde {
                        args.push (plánovač);
                    }
                    return orig.apply (this, args);
                });
            }
        });
    };
    injectFn (pozorovatelné, pozorovatelné metody);
    injectFn (Observable.prototype, operatorMethods);
}

Od této chvíle provede testScheduler veškerou práci uvnitř Rxjs. Nepoužívá setTimeout / setInterval ani žádné asynchronní věci. FakeAsync už není třeba.

Nyní potřebujeme testovací instanci plánovače, kterou chceme předat monkeypatchScheduler.

Chová se velmi podobně jako výchozí TestScheduler, ale poskytuje metodu zpětného volání onAction. Tímto způsobem víme, která akce byla provedena po uplynutí této doby.

exportní třída SpyingTestScheduler rozšiřuje VirtualTimeScheduler {
    spyFn: (actionName: string, delay: number, error ?: any) => void;
    konstruktor () {
        super (VirtualAction, defaultMaxFrame);
    }
    onAction (spyFn: (actionName: string, delay: number, error ?: any) => void) {
        this.spyFn = spyFn;
    }
    flush () {
        const {actions, maxFrames} = this;
        let error: any, action: AsyncAction ;
        while ((action = actions.shift ()) && (this.frame = action.delay) <= maxFrames) {
            let stateName = this.detectStateName (action);
            let delay = action.delay;
            if (error = action.execute (action.state, action.delay)) {
                if (this.spyFn) {
                    this.spyFn (stateName, delay, error);
                }
                přestávka;
            } jinde {
                if (this.spyFn) {
                    this.spyFn (stateName, delay);
                }
            }
        }
        if (chyba) {
            while (action = actions.shift ()) {
                action.unsubscribe ();
            }
            hodová chyba;
        }
    }
    private DetectStateName (akce: AsyncAction ): string {
        const c = Object.getPrototypeOf (action.state) .constructor;
        const argsPos = c.toString (). indexOf ('(');
        if (argsPos! == -1) {
            návrat c.toString (). podřetězec (9, argsPos);
        }
        návrat null;
    }
}

Nakonec se podívejme na použití. Příkladem je stejný test jednotky, jaký byl použit dříve („vymaže předchozí výsledek“), s malým rozdílem, že místo fakeAsync / tick použijeme plánovač testů.

nechat testScheduler;
beforeEach (() => {
    testScheduler = new SpyingTestScheduler ();
    testScheduler.maxFrames = 1000000;
    monkeypatchScheduler (testScheduler);
    fixture.detectChanges ();
});
beforeEach (() => {
    spyOn (apiService, 'query'). a.callFake (() => {
        return Observable.of (queryResult) .delay (REQUEST_DELAY);
    });
});
to ('vymaže předchozí výsledek', (done: Function) => {
    comp.options = ['non empty'];
    testScheduler.onAction ((actionName: string, delay: number, err ?: any) => {
        if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
            očekávat (comp.options.length) .toBe (0, `byl [$ {comp.options.join (',')}]`);
            Hotovo();
        }
    });
    SpecUtils.focusAndInput ('Londo', příslušenství, 'input');
    fixture.detectChanges ();
    testScheduler.flush ();
});

Plánovač testů je vytvořen a monkeypatched (!) V prvním před každým. Ve druhé před každou položkou jsme prozkoumali apiService.query, abychom poskytli výsledek dotazu Výsledek po REQUEST_DELAY = 5000 milisekund.

Nyní jdeme projít it-line po řádku:

  1. Nejprve si uvědomte, že deklarujeme vykonanou funkci, kterou potřebujeme ve spojení s zpětným zavoláním testovacího plánovače onAction. To znamená, že musíme Jasmine říct, že test se provádí sám.
to ('vymaže předchozí výsledek', (done: Function) => {

2. Opět předstíráme některé možnosti obsažené v komponentě.

comp.options = ['non empty'];

3. To vyžaduje určité vysvětlení, protože na první pohled to vypadá trochu nemotorně. Chceme čekat na akci nazvanou „DebounceTimeSubscriber“ se zpožděním DEBOUNCING_VALUE = 300 milisekund. Když k tomu dojde, chceme zkontrolovat, zda je parametr options.length 0. Je test dokončen a my voláme done ().

testScheduler.onAction ((actionName: string, delay: number, err ?: any) => {
    if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
      očekávat (comp.options.length) .toBe (0, `byl [$ {comp.options.join (',')}]`);
      Hotovo();
    }
});

Vidíte, že použití testovacích plánovačů vyžaduje určité speciální znalosti o interních implementacích Rxjs. Samozřejmě záleží na tom, jaký testovací plánovač používáte, ale i když implementujete výkonný plánovač na vlastní pěst, budete muset pochopit plánovače a odhalit některé runtime hodnoty pro flexibilitu (což opět nemusí být samo-vysvětlující).

4. Uživatel opět zadá hodnotu „Londo“.

SpecUtils.focusAndInput ('Londo', příslušenství, 'input');

5. Znovu zjistěte změny a znovu vykreslete šablonu.

fixture.detectChanges ();

6. Nakonec provedeme všechny akce umístěné ve frontě plánovače.

testScheduler.flush ();

souhrn

Angularovy vlastní testovací nástroje jsou vhodnější než ty, které si vyrobili sami ... pokud fungují. V některých případech pár fakeAsync / tick nefunguje, ale není důvod zoufale a vynechat testy jednotek. V těchto případech je automatická obslužná utilita (zde také známá jako AsyncZoneTimeInSyncKeeper) nebo vlastní testovací plánovač (zde také známý jako SpyingTestScheduler).

Zdrojový kód