Angular: Testarea lucrurilor async în zona falsificată Async VS. furnizarea de programe personalizate

Mi s-au pus întrebări de multe ori despre „zona falsă” și cum să o folosesc. De aceea am decis să scriu acest articol pentru a-mi împărtăși observațiile atunci când vine vorba de teste „fakeAsync” cu granulație fină.

Zona este o parte crucială a ecosistemului unghiular. S-ar fi putut citi că zona în sine este doar un fel de „context de execuție”. De fapt, Angular Monkey controlează funcțiile globale, cum ar fi setTimeout sau setInterval, pentru a intercepta funcțiile care se execută după o anumită întârziere (setTimeout) sau periodic (setInterval).

Este important să menționăm că acest articol nu va arăta cum să faci față cu hack-urile setTimeout. Deoarece Angular folosește intens RxJ-urile pe care se bazează pe funcțiile de cronometrare native (s-ar putea să fiți surprinși, dar este adevărat), utilizează zona ca un instrument complex, dar puternic, pentru a înregistra toate acțiunile asincrone care ar putea afecta starea aplicației. Unghiurile le interceptează pentru a ști dacă mai există ceva de lucru la coadă. Durează coada în funcție de timp. Cel mai probabil, sarcinile scurse modifică valorile variabilelor componente. Drept urmare, șablonul este redat.

Acum, toate lucrurile async nu sunt ceea ce trebuie să ne facem griji. Este drăguț să înțelegi ce se întâmplă sub capotă, deoarece ajută la scrierea testelor eficiente ale unității. Mai mult, dezvoltarea bazată pe teste are un impact uriaș asupra codului sursă („originile TDD au fost dorința de a obține teste automate de regresie puternice care au susținut designul evolutiv. Pe parcursul practicanților săi au descoperit că testele de scriere au făcut mai întâi o îmbunătățire semnificativă a procesului de proiectare). „Martin Fowler, https://martinfowler.com/articles/mocksArentStubs.html, 09/2017).

Ca urmare a tuturor acestor eforturi, putem schimba timpul așa cum trebuie să testăm starea la un moment dat specific.

fakeAsync / bifează conturul

Documentele Angular precizează că fakeAsync (https://angular.io/guide/testing#fake-async) oferă o experiență de codare mai liniară, deoarece scapă de promisiuni precum .whenStable (), apoi (...).

Codul din interiorul blocului fakeAsync arată astfel:

căpușă (100); // așteptați prima sarcină
fixture.detectChanges (); // actualizare vizualizare cu citat
căpușă (); // așteptați finalizarea celei de-a doua sarcini
fixture.detectChanges (); // actualizare vizualizare cu citat

Următoarele fragmente oferă câteva informații despre modul în care funcționează fakeAsync.

setTimeout / setInterval sunt utilizate aici, deoarece acestea arată clar când funcțiile se execută în zona fakeAsync. S-ar putea să vă așteptați că această funcție „it” trebuie să știe când se face testul (în Jasmine aranjat prin argumentul făcut: Funcție), dar de data aceasta ne bazăm pe tovarășul fakeAsync, mai degrabă decât să folosim orice fel de apelare inversă:

it ('drenează sarcina zonei prin sarcină', fakeAsync (() => {
        setTimeout (() => {
            fie i = 0;
            const handle = setInterval (() => {
                if (i ++ === 5) {
                    clearInterval (mâner);
                }
            }, 1000);
        }, 10000);
}));

Se plânge tare, deoarece există încă câteva „cronometre” (= setTimeouts) în coadă:

Eroare: 1 cronometru (e) încă în coadă.

Este evident că trebuie să schimbăm timpul pentru a finaliza funcția de timp. Adăugăm parametrul „bifat” cu 10 secunde:

căpușă (10000);

Hugh? Eroarea devine mai confuză. Acum, testul eșuează din cauza „cronometrelor periodice” (= setări internaționale):

Eroare: 1 cronometru periodic (e) încă în coadă.

Deoarece am solicitat o funcție, ceea ce trebuie executat în fiecare secundă, de asemenea, trebuie să schimbăm timpul folosind din nou bifați. Funcția se încheie singură după 5 secunde. De aceea trebuie să adăugăm încă 5 secunde:

căpușă (15000);

Acum, testul trece. Merită să spunem că zona recunoaște sarcinile care se desfășoară în paralel. Doar extindeți funcția de expirare cu un alt apel SetInterval.

it ('drenează sarcina zonei prin sarcină', fakeAsync (() => {
    setTimeout (() => {
        fie i = 0;
        const handle = setInterval (() => {
            if (++ i === 5) {
                clearInterval (mâner);
            }
        }, 1000);
        fie j = 0;
        const handle2 = setInterval (() => {
            if (++ j === 3) {
                clearInterval (handle2);
            }
        }, 1000);
    }, 10000);
    căpușă (15000);
}));

Testul continuă, deoarece ambele seturi de control au fost începute în același moment. Ambele sunt efectuate când trec 15 secunde:

falsAsync / bifează în acțiune

Acum știm cum funcționează lucrurile fakeAsync / tick. Lasă-l să folosească pentru unele lucruri semnificative.

Să dezvoltăm un câmp asemănător sugestiilor care îndeplinește aceste cerințe:

  • preia rezultatul unor API (servicii)
  • accelerează intrarea utilizatorului pentru a aștepta termenul final de căutare (scade numărul de solicitări); DEBOUNCING_VALUE = 300
  • afișează rezultatul în UI și emite mesajul corespunzător
  • testul unității respectă natura asincronă a codului și testează comportamentul corespunzător al câmpului asemănător în termenii trecuți

Terminăm cu aceste scenarii de testare:

descrie ('la căutare', () => {
    it ('șterge rezultatul anterior', fakeAsync (() => {
    }));
    it ('emite semnalul de pornire', fakeAsync (() => {
    }));
    it ('este accelerarea posibilelor accesări ale API-ului la 1 cerere de DEBOUNCING_VALUE miliseconds', fakeAsync (() => {
    }));
});
descrie ('la succes', () => {
    it ('apelează API-ul Google', fakeAsync (() => {
    }));
    it ('emite semnalul de succes cu numărul de potriviri', fakeAsync (() => {
    }));
    it ('arată titlurile din câmpul sugestiv', fakeAsync (() => {
    }));
});
descrie ('la eroare', () => {
    it ('emite semnalul de eroare', fakeAsync (() => {
    }));
});

În „la căutare” nu așteptăm rezultatul căutării. Când utilizatorul furnizează o intrare (de exemplu „Lon”), opțiunile anterioare trebuie șterse. Ne așteptăm ca opțiunile să fie goale. În plus, intrarea utilizatorului trebuie să fie accelerată, să zicem cu o valoare de 300 de milisecunde. În ceea ce privește zona, un microtask de 300 de milischi este împins în coadă.

Rețineți, că omit câteva detalii pentru scurtitate:

  • configurarea testului este aproximativ aceeași cu cea din documentele Angular
  • instanța apiService este injectată prin fixture.debugElement.injector (...)
  • SpecUtils declanșează evenimente legate de utilizator, cum ar fi introducerea și focalizarea
beforeEach (() => {
    spyOn (apiService, 'interogare'). și.returnValue (Observable.of (queryResult));
});
fit ('șterge rezultatul anterior', fakeAsync (() => {
    comp.options = ['nu este gol'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    căpușă (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    expect (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]));
}));

Codul componente care încearcă să satisfacă testul:

ngOnInit () {
    this.control.valueChanges.debounceTime (300) .subscribe (valoare => {
        this.options = [];
        this.suggest (valoare);
    });
}
sugerează (q: șir) {
    this.googleBooksAPI.query (q) .subscribe (rezultat => {
// ...
    }, () => {
// ...
    });
}

Să parcurgem codul pas cu pas:

Spionăm metoda de interogare apiService pe care o vom numi în componentă. Variabila queryResult conține câteva date de tipar, cum ar fi „Hamlet”, „Macbeth” și „King Lear”. La început ne așteptăm ca opțiunile să fie goale, dar, așa cum ați observat, întreaga coadă falsă Async se scurge cu tick (DEBOUNCING_VALUE) și, prin urmare, componenta conține rezultatul final al scrierilor lui Shakespeare:

Se aștepta ca 3 să fie 0, „a fost [Hamlet, Macbeth, King Lear]”.

Avem nevoie de o întârziere pentru solicitarea de interogare a serviciului pentru a emula un timp asincron de timp consumat de apelul API. Să adăugăm 5 secunde întârziere (REQUEST_DELAY = 5000) și bifăm (5000).

beforeEach (() => {
    spyOn (apiService, 'interogare'). și.returnValue (Observable.of (queryResult) .delay (1000));
});

fit ('șterge rezultatul anterior', fakeAsync (() => {
    comp.options = ['nu este gol'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    căpușă (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    expect (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]));
    căpușă (REQUEST_DELAY);
}));

În opinia mea, acest exemplu ar trebui să funcționeze, dar Zone.js susține că mai există ceva de lucru în coadă:

Eroare: 1 cronometru periodic (e) încă în coadă.

În acest moment, trebuie să mergem mai adânc pentru a vedea acele funcții despre care bănuim că s-au blocat în zonă. Setarea unor puncte de pauză este calea de urmat:

depanare zonă falsăAsync

Apoi, emiteți acest lucru pe linia de comandă

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

sau examinați conținutul zonei astfel:

hmmm, metoda flush a AsyncScheduler este încă la coadă ... de ce?

Numele funcției solicitate este metoda de încărcare a AsyncScheduler.

public flush (acțiune: AsyncAction ): void {
  const {actions} = this;
  if (this.active) {
    actions.push (acțiune);
    întoarcere;
  }
  let error: oricare;
  this.active = true;
  face {
    if (eroare = action.execute (action.state, action.delay)) {
      pauză;
    }
  } while (action = actions.shift ()); // epuiza coada de programare
  this.active = false;
  if (eroare) {
    while (action = actions.shift ()) {
      action.unsubscribe ();
    }
    eroare de aruncare;
  }
}

Acum, s-ar putea să vă întrebați ce este greșit cu codul sursă sau zona în sine.

Problema este că zona și căpușele noastre nu sunt sincronizate.

Zona în sine are ora curentă (2017), în timp ce căpușa dorește să proceseze acțiunea programată la 01.01.1970 + 300 milis + 5 secunde.

Valoarea planificatorului async confirmă că:

import {async ca AsyncScheduler} din „rxjs / planificator / async”;
// așezați acest lucru undeva în interiorul „it”
console.info (AsyncScheduler.now ());
// → 1503235213879

AsyncZoneTimeInSyncKeeper la salvare

O soluție posibilă pentru aceasta este să ai o utilitate de păstrare în sincronizare ca aceasta:

export class AsyncZoneTimeInSyncKeeper {
    timp = 0;
    constructor () {
        spyOn (AsyncScheduler, 'acum'). și.callFake (() => {
            / * tslint: dezactivare-linia următoare * /
            console.info ('time', this.time);
            întoarceți acest lucru.time;
        });
    }
    bifați (ora ?: număr) {
        if (tipof time! == 'undefined') {
            this.time + = time;
            căpușă (this.time);
        } altfel {
            căpușă ();
        }
    }
}

Acesta ține evidența timpului curent care este returnat de acum () ori de câte ori este apelat programatorul de async. Aceasta funcționează deoarece funcția tick () utilizează același timp curent. Ambele, programatorul și zona, au același timp.

Vă recomand să inițiați oraInSyncKeeper în faza BeforeEach:

descrie ('la căutare', () => {
    lasa timpulInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
    });
});

Acum, să aruncăm o privire la modul de utilizare a dispozitivului de sincronizare a timpului. Rețineți că trebuie să abordăm această problemă de sincronizare, deoarece câmpul text este dezbătut și solicitarea durează ceva timp.

descrie („la căutare”, () => {
    lasa timpulInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
        spyOn (apiService, 'interogare'). și.returnValue (Observable.of (queryResult) .delay (REQUEST_DELAY));
    });
    it ('șterge rezultatul anterior', fakeAsync (() => {
        comp.options = ['nu este gol'];
        SpecUtils.focusAndInput ('Lon', fixture, 'input');
        timeInSyncKeeper.tick (DEBOUNCING_VALUE);
        fixture.detectChanges ();
        expect (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]));
        timeInSyncKeeper.tick (REQUEST_DELAY);
    }));
    // ...
});

Să parcurgem acest exemplu linie cu rând:

  1. instantaneu instanța de gestiune sincronizare
timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();

2. să răspundem la metoda apiService.query cu rezultatul queryResult după ce REQUEST_DELAY a trecut. Să spunem că metoda de interogare este lentă și răspunde după REQUEST_DELAY = 5000 milisecunde.

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

3. Pretindeți că există o opțiune „non goală” prezentă în câmpul Sugestie

comp.options = ['nu este gol'];

4. Accesați câmpul „intrare” din elementul nativ al aparatului și introduceți valoarea „Lon”. Acest lucru simulează interacțiunea utilizatorului cu câmpul de intrare.

SpecUtils.focusAndInput ('Lon', fixture, 'input');

5. să trecem perioada DEBOUNCING_VALUE în zona falsă de async (DEBOUNCING_VALUE = 300 milisecunde).

timeInSyncKeeper.tick (DEBOUNCING_VALUE);

6. Detectează modificările și redă șablonul HTML.

fixture.detectChanges ();

7. Matricea de opțiuni este goală acum!

expect (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]));

Aceasta înseamnă că valorile observabile Schimbările utilizate în componente au fost gestionate la momentul potrivit. Rețineți că funcția debounceTime-d executată

valoare => {
    this.options = [];
    this.onEvent.emit ({semnal: SuggestSignal.start});
    this.suggest (valoare);
}

a împins o altă sarcină în coadă apelând metoda sugerează:

sugerează (q: șir) {
    if (! q) {
        întoarcere;
    }
    this.googleBooksAPI.query (q) .subscribe (rezultat => {
        if (rezultat) {
            this.options = rezultat.items.map (item => item.volumeInfo);
            this.onEvent.emit ({semnal: SuggestSignal.success, totalItems: result.totalItems});
        } altfel {
            this.onEvent.emit ({semnal: SuggestSignal.success, TotalItems: 0});
        }
    }, () => {
        this.onEvent.emit ({semnal: SuggestSignal.error});
    });
}

Nu uitați să vă amintiți de spionul din metoda de interogare a API-urilor Google Books care răspunde după 5 secunde.

8. În sfârșit, trebuie să bifăm din nou pentru REQUEST_DELAY = 5000 milisecunde pentru a umple coada zonei. Observabilul pe care îl abonăm în metoda sugerează nevoie de REQUEST_DELAY = 5000 pentru a fi completat.

timeInSyncKeeper.tick (REQUEST_DELAY);

fakeAsync ...? De ce? Există programări!

Experții ReactiveX ar putea susține că am putea folosi programatorii de testare pentru a face testările observabile. Este posibil pentru aplicații unghiulare, dar prezintă unele dezavantaje:

  • necesită să vă familiarizați cu structura interioară a observabililor, operatorilor, ...
  • Ce se întâmplă dacă aveți niște soluții urâte de rezolvare a timpului în aplicație? Nu sunt gestionate de programatori.
  • cea mai importantă: sunt sigur că nu doriți să utilizați programatori în întreaga aplicație. Nu doriți să amestecați codul de producție cu testele unității. Nu vrei să faci așa ceva:
const testScheduler;
if (environment.test) {
    testScheduler = new YourTestScheduler ();
}
lasa observabil;
if (testScheduler) {
    observable = Observable.of ("valoare"). întârziere (1000, testScheduler)
} altfel {
    observable = Observable.of ("valoare"). întârziere (1000);
}

Aceasta nu este o soluție viabilă. În opinia mea, singura soluție posibilă este „injectarea” programatorului de testare prin furnizarea de tipuri de „proxies” pentru metodele Rxjs reale. Un alt lucru de luat în considerare este faptul că metodele de supraalimentare ar putea influența negativ testele de unitate rămase. Acesta este motivul pentru care vom folosi spionii iasomiei. Spionii se curăță după fiecare.

Funcția monkeypatchScheduler înfășoară implementarea Rxjs originală folosind un spion. Spionul ia argumentele metodei și adaugă testScheduler dacă este cazul.

import {IScheduler} din 'rxjs / Scheduler';
import {Observable} din „rxjs / Observable”;
declare var spyOn: Funcție;
export funcție monkeypatchScheduler (planificator: IScheduler) {
    let observableMethods = ['concat', 'amânare', 'gol', 'furcăJoin', 'dacă', 'interval', 'îmbinare', 'din', 'interval', 'aruncare',
        'Zip'];
    let operatorMethods = ['tampon', 'concat', 'întârziere', 'distinct', 'a face', 'fiecare', 'ultimul', 'a îmbina', 'maxim', 'a lua',
        'timeInterval', 'lift', 'debounceTime'];
    let injectFn = function (baza: orice, metode: string []) {
        method.forEach (metodă => {
            const orig = bază [metodă];
            if (typeof orig === 'function') {
                spyOn (bază, metodă). și.callFake (funcție () {
                    let args = Array.prototype.slice.call (argumente);
                    if (args [args.length - 1] && typeof args [args.length - 1] .now === 'function') {
                        args [args.length - 1] = planificator;
                    } altfel {
                        args.push (scheduler);
                    }
                    return orig.apply (aceasta, args);
                });
            }
        });
    };
    injectFn (Metode observabile, observabile);
    injectFn (Observable.prototype, operatorMethods);
}

De acum înainte, testScheduler va executa toate lucrările din Rxjs. Nu folosește setTimeout / setInterval sau orice fel de chestii async. Nu mai este nevoie de falsAsync.

Acum, avem nevoie de o instanță de planificare a testului pe care vrem să o trecem la monkeypatchScheduler.

Se comportă foarte mult ca TestScheduler implicit, dar oferă o metodă de apelare onAction. În acest fel, știm ce acțiune a fost executată după ce perioadă de timp.

export class SpyingTestScheduler extinde VirtualTimeScheduler {
    spyFn: (actionName: șir, întârziere: număr, eroare ?: any) => void;
    constructor () {
        super (VirtualAction, defaultMaxFrame);
    }
    onAction (spyFn: (actionName: string, delay: număr, eroare ?: 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 (acțiune);
            let delay = action.delay;
            if (eroare = action.execute (action.state, action.delay)) {
                if (this.spyFn) {
                    this.spyFn (nume de stat, întârziere, eroare);
                }
                pauză;
            } altfel {
                if (this.spyFn) {
                    this.spyFn (nume de stat, întârziere);
                }
            }
        }
        if (eroare) {
            while (action = actions.shift ()) {
                action.unsubscribe ();
            }
            eroare de aruncare;
        }
    }
    private detectStateName (acțiune: AsyncAction ): string {
        const c = Object.getPrototypeOf (action.state) .constructor;
        const argsPos = c.toString (). indexOf ('(');
        if (argsPos! == -1) {
            return c.toString (). substring (9, argsPos);
        }
        returnare nulă;
    }
}

În cele din urmă, să aruncăm o privire asupra utilizării. Exemplul este același test unitar ca cel utilizat anterior (acesta („șterge rezultatul anterior”) cu o mică diferență că vom folosi programatorul de test în loc de fakeAsync / tick.

lasa testScheduler;
beforeEach (() => {
    testScheduler = new SpyingTestScheduler ();
    testScheduler.maxFrames = 1000000;
    monkeypatchScheduler (testScheduler);
    fixture.detectChanges ();
});
beforeEach (() => {
    spyOn (apiService, 'interogare'). și.callFake (() => {
        returneaza Observable.of (queryResult) .delay (REQUEST_DELAY);
    });
});
it ('șterge rezultatul anterior', (done: Function) => {
    comp.options = ['nu este gol'];
    testScheduler.onAction ((actionName: șir, întârziere: număr, err ?: any) => {
        if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
            expect (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]));
            Terminat();
        }
    });
    SpecUtils.focusAndInput ('Londo', accesoriu, 'intrare');
    fixture.detectChanges ();
    testScheduler.flush ();
});

Planificatorul de testare este creat și monografiat (!) În primul înainteEach. În al doilea înainteEach, spionăm apiService.query pentru a servi interogarea rezultatelorResult după REQUEST_DELAY = 5000 milisecunde.

Acum, hai să parcurgem linia cu linie:

  1. Mai întâi de toate, rețineți că declaram funcția finalizată de care avem nevoie în legătură cu apelarea de apelare a programatorului de testare. Aceasta înseamnă că trebuie să-i spunem lui Jasmine că testul este făcut pe cont propriu.
it ('șterge rezultatul anterior', (done: Function) => {

2. Din nou, prefacem câteva opțiuni prezente în componentă.

comp.options = ['nu este gol'];

3. Acest lucru necesită o explicație, deoarece pare a fi puțin stângaci la prima vedere. Vrem să așteptăm o acțiune numită „DebounceTimeSubscriber” cu o întârziere de DEBOUNCING_VALUE = 300 milisecunde. Când se întâmplă acest lucru, dorim să verificăm dacă opțiunile.lungimea este 0. Apoi, testul este finalizat și apelăm făcut ().

testScheduler.onAction ((actionName: șir, întârziere: număr, err ?: any) => {
    if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
      expect (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]));
      Terminat();
    }
});

Vedeți că utilizarea programatorilor de testare necesită cunoștințe speciale despre internele de implementare Rxjs. Desigur, depinde ce planificator de test utilizați, dar chiar dacă implementați un programator puternic, va trebui să înțelegeți planificatorii și să expuneți anumite valori de rulare pentru flexibilitate (care, din nou, s-ar putea să nu fie auto-explicative).

4. Din nou, utilizatorul introduce valoarea „Londo“.

SpecUtils.focusAndInput ('Londo', accesoriu, 'intrare');

5. Din nou, detectați modificările și redonați șablonul.

fixture.detectChanges ();

6. În cele din urmă, executăm toate acțiunile plasate în coada planificatorului.

testScheduler.flush ();

rezumat

Utilitățile de testare ale angularului sunt de preferat celor auto-făcute ... atât timp cât funcționează. În unele cazuri, cuplul falsAsync / tick nu funcționează, dar nu există niciun motiv să disperați și să omiteți testele unității. În aceste cazuri, un utilitar de sincronizare automată (cunoscut și sub numele de AsyncZoneTimeInSyncKeeper) sau un programator de testare personalizat (cunoscut și sub numele de SpyingTestScheduler) este calea de urmat.

Cod sursa