Angular: async-spullen testen in de nepzone Synsy zone VS. aangepaste planners bieden

Er zijn mij vaak vragen gesteld over de "nepzone" en hoe deze te gebruiken. Daarom heb ik besloten om dit artikel te schrijven om mijn observaties te delen als het gaat om fijnmazige "fakeAsync" -tests.

De zone is een cruciaal onderdeel van het hoekige ecosysteem. Je zou kunnen hebben gelezen dat de zone zelf slechts een soort "uitvoeringscontext" is. In feite pakt Angular monkeypatch de globale functies zoals setTimeout of setInterval om functies te onderscheppen die na enige vertraging (setTimeout) of periodiek (setInterval) worden uitgevoerd.

Het is belangrijk om te vermelden dat dit artikel niet laat zien hoe om te gaan met setTimeout-hacks. Omdat Angular intensief gebruik maakt van RxJ's die afhankelijk zijn van native timingfuncties (je zult je misschien verbazen maar het is waar), gebruikt het zone als een complex maar krachtig hulpmiddel om alle asynchrone acties vast te leggen die de status van de applicatie kunnen beïnvloeden. Angular onderschept ze om te weten of er nog wat werk in de wachtrij staat. Afhankelijk van de tijd wordt de wachtrij leeggemaakt. Hoogstwaarschijnlijk veranderen de gedraineerde taken de waarden van de componentvariabelen. Als gevolg hiervan wordt de sjabloon opnieuw gerenderd.

Nu hoeven we ons geen zorgen te maken over al het async-gedoe. Het is gewoon leuk om te begrijpen wat er onder de motorkap gebeurt, omdat het helpt bij het schrijven van effectieve unit-tests. Bovendien heeft testgestuurde ontwikkeling een enorme impact op de broncode („TDD's oorsprong was de wens om sterke automatische regressietests te krijgen die het evolutionaire ontwerp ondersteunden. Onderweg ontdekten de vakmensen dat het schrijven van tests eerst een aanzienlijke verbetering van het ontwerpproces was. “Martin Fowler, https://martinfowler.com/articles/mocksArentStubs.html, 09/2017).

Als gevolg van al deze inspanningen kunnen we de tijd verleggen omdat we op een specifiek tijdstip moeten testen op staat.

fakeAsync / tick-overzicht

De Angular docs stelt dat de fakeAsync (https://angular.io/guide/testing#fake-async) een meer lineaire codeerervaring biedt omdat het beloften zoals .whenStable (). Dan (...) verlost.

De code in het blok fakeAsync ziet er als volgt uit:

tick (100); // wacht tot de eerste taak is voltooid
fixture.detectChanges (); // update weergave met citaat
Kruis aan(); // wacht tot de tweede taak is voltooid
fixture.detectChanges (); // update weergave met citaat

De volgende fragmenten geven enkele inzichten in de manier waarop fakeAsync werkt.

setTimeout / setInterval worden hier gebruikt omdat ze duidelijk laten zien wanneer de functies worden uitgevoerd in de zone fakeAsync. Je zou kunnen verwachten dat deze "it" -functie moet weten wanneer de test is gedaan (in Jasmine gerangschikt op argument gedaan: Functie), maar deze keer vertrouwen we op de fakeAsync-metgezel in plaats van enige vorm van callback:

it ('maakt de zone taak per taak leeg', fakeAsync (() => {
        setTimeout (() => {
            laat i = 0;
            const handle = setInterval (() => {
                if (i ++ === 5) {
                    clearInterval (handgreep);
                }
            }, 1000);
        }, 10000);
}));

Het klaagt hard omdat er nog enkele "timers" (= setTimeouts) in de wachtrij staan:

Fout: 1 timer (s) nog in de wachtrij.

Het is duidelijk dat we de tijd moeten verschuiven om de time-outfunctie te voltooien. We voegen de geparametriseerde "tik" toe met 10 seconden:

tick (10000);

Hugh? De fout wordt meer verwarrend. Nu mislukt de test vanwege de "periodieke timers" (= setIntervals):

Fout: 1 periodieke timer (s) nog in de wachtrij.

Omdat we een functie hebben verkregen die elke seconde moet worden uitgevoerd, moeten we de tijd ook verschuiven door het vinkje opnieuw te gebruiken. De functie wordt na 5 seconden beëindigd. Daarom moeten we nog 5 seconden toevoegen:

tick (15000);

Nu is de test geslaagd. Het is de moeite waard om te zeggen dat de zone taken herkent die parallel lopen. Breid de time-outfunctie gewoon uit met een andere setInterval-aanroep.

it ('maakt de zone taak per taak leeg', fakeAsync (() => {
    setTimeout (() => {
        laat i = 0;
        const handle = setInterval (() => {
            if (++ i === 5) {
                clearInterval (handgreep);
            }
        }, 1000);
        laat j = 0;
        const handle2 = setInterval (() => {
            if (++ j === 3) {
                clearInterval (handle2);
            }
        }, 1000);
    }, 10000);
    tick (15000);
}));

De test slaagt nog steeds omdat beide setIntervals op hetzelfde moment zijn gestart. Beide zijn klaar als 15 seconden zijn verstreken:

fakeAsync / tick in actie

Nu weten we hoe het nepAsync / teken-spul werkt. Laat het gebruiken voor een aantal betekenisvolle dingen.

Laten we een suggestieveld ontwikkelen dat aan deze vereisten voldoet:

  • het grijpt het resultaat van sommige API (service)
  • het beperkt de invoer van de gebruiker om op de laatste zoekterm te wachten (het vermindert het aantal verzoeken); DEBOUNCING_VALUE = 300
  • het toont het resultaat in de gebruikersinterface en verzendt het juiste bericht
  • de eenheidstest respecteert de asynchrone aard van de code en test het juiste gedrag van het suggestie-achtige veld in termen van de verstreken tijd

We eindigen met deze testscenario's:

beschrijven ('on search', () => {
    it ('wist het vorige resultaat', fakeAsync (() => {
    }));
    it ('geeft het startsignaal', fakeAsync (() => {
    }));
    it ('beperkt de mogelijke hits van de API tot 1 verzoek per DEBOUNCING_VALUE milliseconds', fakeAsync (() => {
    }));
});
beschrijven ('on success', () => {
    it ('noemt de Google API', fakeAsync (() => {
    }));
    it ('zendt het successignaal uit met het aantal overeenkomsten', fakeAsync (() => {
    }));
    it ('toont de titels in het suggestieveld', fakeAsync (() => {
    }));
});
beschrijven ('on error', () => {
    it ('zendt het foutsignaal uit', fakeAsync (() => {
    }));
});

In "on search" wachten we niet op het zoekresultaat. Wanneer de gebruiker een invoer geeft (bijvoorbeeld "Lon"), moeten de vorige opties worden gewist. We verwachten dat opties leeg zijn. Bovendien moet gebruikersinvoer worden beperkt, laten we zeggen met een waarde van 300 milliseconden. In termen van de zone wordt een 300 millis-microtask in de wachtrij geduwd.

Merk op dat ik sommige details weglaat voor de beknoptheid:

  • de testopstelling is vrijwel hetzelfde als in Angular docs
  • de apiService-instantie wordt geïnjecteerd via fixture.debugElement.injector (…)
  • SpecUtils activeert de gebruikersgerelateerde gebeurtenissen zoals invoer en focus
beforeEach (() => {
    spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult));
});
fit ('wist het vorige resultaat', fakeAsync (() => {
    comp.options = ['niet leeg'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    tick (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
}));

De componentcode die probeert te voldoen aan de test:

ngOnInit () {
    this.control.valueChanges.debounceTime (300) .subscribe (value => {
        this.options = [];
        this.suggest (value);
    });
}
suggest (q: string) {
    this.googleBooksAPI.query (q) .subscribe (result => {
// ...
    }, () => {
// ...
    });
}

Laten we de code stap voor stap doorlopen:

We bespioneren de apiService-querymethode die we in de component gaan aanroepen. De variabele queryResult bevat enkele nepgegevens zoals "Hamlet", "Macbeth" en "King Lear". In het begin verwachten we dat de opties leeg zijn, maar zoals je misschien hebt gemerkt, wordt de hele fakeAsync-wachtrij leeggemaakt met vinkje (DEBOUNCING_VALUE) en daarom bevat het onderdeel ook het eindresultaat van de geschriften van Shakespeare:

Verwachtte dat 3 0 was, 'was [Hamlet, Macbeth, King Lear]'.

We hebben een vertraging nodig voor het verzoek om een ​​servicequery om een ​​asynchrone tijdsverloop van de API-aanroep te emuleren. Laten we 5 seconden vertraging toevoegen (REQUEST_DELAY = 5000) en vinken (5000).

beforeEach (() => {
    spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult) .delay (1000));
});

fit ('wist het vorige resultaat', fakeAsync (() => {
    comp.options = ['niet leeg'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    tick (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
    tick (REQUEST_DELAY);
}));

Naar mijn mening zou dit voorbeeld moeten werken, maar Zone.js beweert dat er nog wat werk in de wachtrij staat:

Fout: 1 periodieke timer (s) nog in de wachtrij.

Op dit punt moeten we dieper gaan om die functies te zien waarvan we vermoeden dat ze vast komen te zitten in de zone. Het instellen van enkele breekpunten is de beste keuze:

foutopsporing fakeAsync-zone

Geef dit vervolgens op de opdrachtregel op

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

of bekijk de inhoud van de zone als volgt:

hmmm, de spoelmethode van AsyncScheduler staat nog steeds in de wachtrij ... waarom?

De naam van de functie die wordt opgehaald, is de spoelmethode van AsyncScheduler.

public flush (actie: AsyncAction ): void {
  const {actions} = dit;
  if (this.active) {
    actions.push (actie);
    terug te keren;
  }
  let error: any;
  this.active = waar;
  Doen {
    if (error = action.execute (action.state, action.delay)) {
      breken;
    }
  } while (action = actions.shift ()); // put de wachtrij van de planner uit
  this.active = false;
  if (error) {
    while (action = actions.shift ()) {
      action.unsubscribe ();
    }
    gooi fout;
  }
}

Nu vraag je je misschien af ​​wat er mis is met de broncode of zone zelf.

Het probleem is dat de zone en onze teken niet synchroon lopen.

De zone zelf heeft de huidige tijd (2017), terwijl het vinkje de actie wil verwerken die is gepland op 01.01.1970 + 300 millis + 5 seconden.

De waarde van de async-planner bevestigt dat:

import {async as AsyncScheduler} uit 'rxjs / scheduler / async';
// plaats dit ergens in de "it"
console.info (AsyncScheduler.now ());
// → 1503235213879

AsyncZoneTimeInSyncKeeper te hulp

Een mogelijke oplossing hiervoor is om een ​​keep-in-sync-hulpprogramma als dit te hebben:

exportklasse AsyncZoneTimeInSyncKeeper {
    tijd = 0;
    constructor () {
        spyOn (AsyncScheduler, 'now'). and.callFake (() => {
            / * tslint: disable-next-line * /
            console.info ('tijd', this.time);
            retourneer deze.tijd;
        });
    }
    aankruisen (tijd ?: nummer) {
        if (type of time! == 'undefined') {
            this.time + = tijd;
            tick (this.time);
        } anders {
            Kruis aan();
        }
    }
}

Het houdt de huidige tijd bij die wordt geretourneerd door now () wanneer de async-planner wordt aangeroepen. Dit werkt omdat de functie tick () dezelfde huidige tijd gebruikt. Zowel de planner als de zone delen dezelfde tijd.

Ik raad aan om de timeInSyncKeeper in de voorgaande fase te instantiëren:

beschrijven ('on search', () => {
    let timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
    });
});

Laten we nu eens kijken naar het gebruik van de tijdsynchronisatiebewaarder. Houd er rekening mee dat we dit timingprobleem moeten aanpakken, omdat het tekstveld wordt afgebroken en het verzoek enige tijd duurt.

beschrijven (‘on search’, () => {
    let timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
        spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult) .delay (REQUEST_DELAY));
    });
    it ('wist het vorige resultaat', fakeAsync (() => {
        comp.options = ['niet leeg'];
        SpecUtils.focusAndInput ('Lon', fixture, 'input');
        timeInSyncKeeper.tick (DEBOUNCING_VALUE);
        fixture.detectChanges ();
        expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
        timeInSyncKeeper.tick (REQUEST_DELAY);
    }));
    // ...
});

Laten we dit voorbeeld regel voor regel doornemen:

  1. de instantie van de gesynchroniseerde keeper instantiëren
timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();

2. laat de methode apiService.query reageren met resultaatqueryResult nadat REQUEST_DELAY is verstreken. Stel dat de querymethode langzaam is en reageert na REQUEST_DELAY = 5000 milliseconden.

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

3. Doe alsof er een optie 'niet leeg' aanwezig is in het suggestieveld

comp.options = ['niet leeg'];

4. Ga naar het veld "input" in het native element van het toestel en voer de waarde "Lon" in. Dit simuleert de gebruikersinteractie met het invoerveld.

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

5. laat de DEBOUNCING_VALUE-tijdsperiode in de nep-async-zone (DEBOUNCING_VALUE = 300 milliseconden) door.

timeInSyncKeeper.tick (DEBOUNCING_VALUE);

6. Detecteer wijzigingen en geef de HTML-sjabloon opnieuw weer.

fixture.detectChanges ();

7. De optiesarray is nu leeg!

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

Dit betekent dat de waarneembare valueChanges die in de componenten zijn gebruikt, op het juiste moment zijn uitgevoerd. Merk op dat de uitgevoerde debounceTime-d-functie

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

duwde een andere taak in de wachtrij door de methode aan te roepen suggereren:

suggest (q: string) {
    if (! q) {
        terug te keren;
    }
    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});
        } anders {
            this.onEvent.emit ({signal: SuggestSignal.success, totalItems: 0});
        }
    }, () => {
        this.onEvent.emit ({signal: SuggestSignal.error});
    });
}

Denk maar aan de spion op de API-zoekmethode van Google Books die na 5 seconden reageert.

8. Ten slotte moeten we opnieuw aanvinken voor REQUEST_DELAY = 5000 milliseconden om de zonewachtrij te spoelen. De waarneembare waar we ons op abonneren in de voorgestelde methode heeft REQUEST_DELAY = 5000 nodig om te voltooien.

timeInSyncKeeper.tick (REQUEST_DELAY);

fakeAsync ...? Waarom? Er zijn planners!

De ReactiveX-experts zouden kunnen beweren dat we testplanners kunnen gebruiken om de observables testbaar te maken. Het is mogelijk voor hoekige toepassingen, maar het heeft enkele nadelen:

  • het vereist dat je bekend raakt met de innerlijke structuur van observables, operators, ...
  • wat als u een aantal lelijke setTimeout-oplossingen in uw toepassing hebt? Ze worden niet afgehandeld door de planners.
  • de belangrijkste: ik weet zeker dat je geen planners in je hele applicatie wilt gebruiken. U wilt de productiecode niet combineren met uw unit-tests. Je wilt zoiets niet doen:
const testScheduler;
if (environment.test) {
    testScheduler = nieuw YourTestScheduler ();
}
laat waarneembaar;
if (testScheduler) {
    observable = Observable.of (‘waarde’). vertraging (1000, testScheduler)
} anders {
    observable = Observable.of (‘waarde’). vertraging (1000);
}

Dit is geen haalbare oplossing. Naar mijn mening is de enige mogelijke oplossing om de testplanner te 'injecteren' door een soort 'proxy's' te bieden voor de echte Rxj-methoden. Een ander ding om rekening mee te houden, is dat dwingende methoden de resterende eenheidstests negatief kunnen beïnvloeden. Daarom gaan we de spionnen van Jasmine gebruiken. Spionnen worden na alles verwijderd.

De functie monkeypatchScheduler verpakt de originele Rxjs-implementatie met behulp van een spion. De spion neemt de argumenten van de methode en voegt indien nodig de testScheduler toe.

import {IScheduler} uit 'rxjs / Scheduler';
importeer {Observable} uit 'rxjs / Observable';
declareren var spyOn: Functie;
exportfunctie monkeypatchScheduler (planner: IScheduler) {
    let observableMethods = ['concat', 'defer', 'empty', 'forkJoin', 'if', 'interval', 'merge', 'of', 'range', 'throw',
        'Zip'];
    let operatorMethods = ['buffer', 'concat', 'delay', 'distinct', 'do', 'every', 'last', 'merge', 'max', 'take',
        'timeInterval', 'lift', 'debounceTime'];
    let injectFn = function (base: any, method: string []) {
        method.forEach (method => {
            const orig = base [methode];
            if (typeof orig === 'function') {
                spyOn (base, methode) .and.callFake (function () {
                    let args = Array.prototype.slice.call (argumenten);
                    if (args [args.length - 1] && typeof args [args.length - 1] .now === 'function') {
                        args [args.length - 1] = planner;
                    } anders {
                        args.push (scheduler);
                    }
                    terugkeer orig.apply (dit, args);
                });
            }
        });
    };
    injectFn (Observable, observableMethods);
    injectFn (Observable.prototype, operatorMethods);
}

Vanaf nu zal de testScheduler al het werk binnen Rxjs uitvoeren. Het maakt geen gebruik van setTimeout / setInterval of andere async-spullen. Er is geen noodzaak meer voor fakeAsync.

Nu hebben we een testplanninginstantie nodig die we willen doorgeven aan monkeypatchScheduler.

Het gedraagt ​​zich heel erg zoals de standaard TestScheduler maar biedt een callback-methode opAction. Op deze manier weten we welke actie na welke periode is uitgevoerd.

exportklasse SpyingTestScheduler breidt VirtualTimeScheduler uit {
    spyFn: (actionName: string, delay: number, error ?: any) => void;
    constructor () {
        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 (actie);
            let delay = action.delay;
            if (error = action.execute (action.state, action.delay)) {
                if (this.spyFn) {
                    this.spyFn (stateName, vertraging, fout);
                }
                breken;
            } anders {
                if (this.spyFn) {
                    this.spyFn (stateName, vertraging);
                }
            }
        }
        if (error) {
            while (action = actions.shift ()) {
                action.unsubscribe ();
            }
            gooi fout;
        }
    }
    private detectStateName (actie: AsyncAction ): string {
        const c = Object.getPrototypeOf (action.state) .constructor;
        const argsPos = c.toString (). indexOf ('(');
        if (argsPos! == -1) {
            retourneer c.toString (). substring (9, argsPos);
        }
        terugkeer null;
    }
}

Laten we tot slot het gebruik bekijken. Het voorbeeld is dezelfde eenheidstest als eerder gebruikt (it (‘wist het vorige resultaat’)) met het kleine verschil dat we een testplanner gaan gebruiken in plaats van fakeAsync / tick.

laat testScheduler;
beforeEach (() => {
    testScheduler = nieuw SpyingTestScheduler ();
    testScheduler.maxFrames = 1000000;
    monkeypatchScheduler (testScheduler);
    fixture.detectChanges ();
});
beforeEach (() => {
    spyOn (apiService, 'query'). and.callFake (() => {
        retour Observable.of (queryResult) .delay (REQUEST_DELAY);
    });
});
it ('wist het vorige resultaat', (gedaan: functie) => {
    comp.options = ['niet leeg'];
    testScheduler.onAction ((actionName: string, delay: number, err ?: any) => {
        if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
            expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
            gedaan();
        }
    });
    SpecUtils.focusAndInput ('Londo', fixture, 'input');
    fixture.detectChanges ();
    testScheduler.flush ();
});

De testplanner wordt aangemaakt en in de eerste voor elke monkeypatch (!) Geplaatst. In de tweede voorElk bespioneren we apiService.query om de resultaatquery te dienen na REQUEST_DELAY = 5000 milliseconden.

Laten we het nu regel voor regel doornemen:

  1. Merk allereerst op dat we de functie gereed hebben verklaard die we nodig hebben in combinatie met de callback van de testplanner op Actie. Dit betekent dat we Jasmine moeten vertellen dat de test alleen wordt gedaan.
it ('wist het vorige resultaat', (gedaan: functie) => {

2. Nogmaals, we doen net alsof er enkele opties aanwezig zijn in de component.

comp.options = ['niet leeg'];

3. Dit vereist enige uitleg omdat het op het eerste gezicht een beetje onhandig lijkt. We willen wachten op een actie genaamd "DebounceTimeSubscriber" met een vertraging van DEBOUNCING_VALUE = 300 milliseconden. Wanneer dit gebeurt, willen we controleren of de opties.lengte 0. is. Vervolgens is de test voltooid en noemen we done ().

testScheduler.onAction ((actionName: string, delay: number, err ?: any) => {
    if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
      expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
      gedaan();
    }
});

U ziet dat het gebruik van testplanners enige speciale kennis vereist over de interne implementaties van Rxjs. Het hangt er natuurlijk van af welke testplanner je gebruikt, maar zelfs als je zelf een krachtige planner implementeert, moet je planners begrijpen en een aantal runtime-waarden blootleggen voor flexibiliteit (wat ook niet vanzelfsprekend is).

4. Nogmaals, de gebruiker voert de waarde "Londo" in.

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

5. Nogmaals, detecteer wijzigingen en geef de sjabloon opnieuw weer.

fixture.detectChanges ();

6. Ten slotte voeren we alle acties uit die in de wachtrij van de planner zijn geplaatst.

testScheduler.flush ();

Samenvatting

De eigen testhulpprogramma's van Angular hebben de voorkeur boven de zelfgemaakte hulpprogramma's ... zolang ze werken. In sommige gevallen werkt het fakeAsync / teken-paar niet, maar er is geen reden om wanhopig te zijn en de unit-tests weg te laten. In deze gevallen is een hulpprogramma voor automatisch synchroniseren (hier ook bekend als AsyncZoneTimeInSyncKeeper) of een aangepaste testplanner (hier ook bekend als SpyingTestScheduler) de beste keuze.

Broncode