Angolare: test di materiale asincrono nella zona simulata asincrona VS. fornendo programmatori personalizzati

Mi sono state poste molte volte domande sulla "zona falsa" e su come usarla. Ecco perché ho deciso di scrivere questo articolo per condividere le mie osservazioni quando si tratta di test "fakeAsync" a grana fine.

La zona è una parte cruciale dell'ecosistema angolare. Si potrebbe aver letto che la zona stessa è solo una sorta di "contesto di esecuzione". In effetti, il monkeypatch angolare rileva le funzioni globali come setTimeout o setInterval per intercettare le funzioni eseguite dopo un certo ritardo (setTimeout) o periodicamente (setInterval).

È importante ricordare che questo articolo non mostrerà come gestire gli hack di setTimeout. Poiché Angular fa un uso intensivo di RxJs, ciò che si basa su funzioni di temporizzazione native (potresti essere sorpreso ma è vero), utilizza la zona come strumento complesso ma potente per registrare tutte le azioni asincrone che potrebbero influenzare lo stato dell'applicazione. Angular li intercetta per sapere se c'è ancora del lavoro in coda. Svuota la coda in base al tempo. Molto probabilmente, le attività svuotate cambiano i valori delle variabili del componente. Di conseguenza, il modello viene ridistribuito.

Ora, tutte le cose asincrone non sono ciò di cui dobbiamo preoccuparci. È bello capire cosa succede sotto il cofano perché aiuta a scrivere test unitari efficaci. Inoltre, lo sviluppo guidato dai test ha un enorme impatto sul codice sorgente ("Le origini di TDD erano il desiderio di ottenere forti test di regressione automatici a supporto della progettazione evolutiva. Lungo la strada i suoi professionisti hanno scoperto che la scrittura dei test ha apportato un notevole miglioramento al processo di progettazione. “Martin Fowler, https://martinfowler.com/articles/mocksArentStubs.html, 09/2017).

Come risultato di tutti questi sforzi, possiamo spostare il tempo di cui abbiamo bisogno per testare lo stato in un determinato momento.

fakeAsync / tick outline

I documenti angolari affermano che fakeAsync (https://angular.io/guide/testing#fake-async) offre un'esperienza di codifica più lineare perché elimina le promesse come .whenStable (). Then (...).

Il codice all'interno del blocco fakeAsync è simile al seguente:

tick (100); // attendi che la prima attività sia completata
fixture.detectChanges (); // aggiorna la vista con un preventivo
tick (); // attendi che la seconda attività venga completata
fixture.detectChanges (); // aggiorna la vista con un preventivo

I seguenti frammenti forniscono alcune informazioni sul modo in cui fakeAsync funziona.

setTimeout / setInterval vengono utilizzati qui perché mostrano chiaramente quando le funzioni vengono eseguite nella zona fakeAsync. Potresti aspettarti che questa funzione "it" debba sapere quando il test è terminato (in Jasmine organizzato per argomento done: Function) ma questa volta ci affidiamo al compagno fakeAsync piuttosto che usare qualsiasi tipo di callback:

it ('svuota l'attività zona per attività', fakeAsync (() => {
        setTimeout (() => {
            lascia che i = 0;
            const handle = setInterval (() => {
                if (i ++ === 5) {
                    clearInterval (maniglia);
                }
            }, 1000);
        }, 10000);
}));

Si lamenta rumorosamente perché ci sono ancora alcuni "timer" (= setTimeouts) nella coda:

Errore: 1 timer (s) ancora nella coda.

È ovvio che dobbiamo spostare il tempo per eseguire la funzione di timeout. Aggiungiamo il "tick" parametrizzato con 10 secondi:

tick (10000);

Hugh? L'errore diventa più confuso. Ora, il test ha esito negativo a causa dei "timer periodici" accodati (= setIntervals):

Errore: 1 timer periodici ancora nella coda.

Poiché abbiamo accodato una funzione che deve essere eseguita ogni secondo, dobbiamo anche spostare il tempo usando nuovamente il segno di spunta. La funzione si interrompe dopo 5 secondi. Ecco perché dobbiamo aggiungere altri 5 secondi:

tick (15000);

Ora il test sta passando. Vale la pena dire che la zona riconosce le attività in esecuzione in parallelo. Basta estendere la funzione di timeout con un'altra chiamata setInterval.

it ('svuota l'attività zona per attività', fakeAsync (() => {
    setTimeout (() => {
        lascia che i = 0;
        const handle = setInterval (() => {
            if (++ i === 5) {
                clearInterval (maniglia);
            }
        }, 1000);
        lascia j = 0;
        const handle2 = setInterval (() => {
            if (++ j === 3) {
                clearInterval (handle2);
            }
        }, 1000);
    }, 10000);
    tick (15000);
}));

Il test sta ancora superando perché entrambi questi setIntervals sono stati avviati nello stesso momento. Entrambi vengono eseguiti dopo 15 secondi:

fakeAsync / tick in azione

Ora sappiamo come funzionano le cose fakeAsync / tick. Lascialo usare per alcune cose significative.

Sviluppiamo un campo simile a quello che soddisfa questi requisiti:

  • prende il risultato da alcune API (servizio)
  • limita l'input dell'utente per attendere il termine di ricerca finale (diminuisce il numero di richieste); DEBOUNCING_VALUE = 300
  • mostra il risultato nell'interfaccia utente ed emette il messaggio appropriato
  • il test unitario rispetta la natura asincrona del codice e verifica il corretto comportamento del campo simile al suggerimento in termini di tempo trascorso

Finiamo con questi scenari di test:

descrivi ('on search', () => {
    it ('cancella il risultato precedente', fakeAsync (() => {
    }));
    it ('emette il segnale di avvio', fakeAsync (() => {
    }));
    it ('sta limitando i possibili hit dell'API a 1 richiesta per DEBOUNCING_VALUE millisecondi', fakeAsync (() => {
    }));
});
descrivi ('in caso di successo', () => {
    esso ("chiama l'API di google", fakeAsync (() => {
    }));
    it ('emette il segnale di successo con il numero di corrispondenze', fakeAsync (() => {
    }));
    it ('mostra i titoli nel campo suggerimento', fakeAsync (() => {
    }));
});
descrivi ('on error', () => {
    it ('emette il segnale di errore', fakeAsync (() => {
    }));
});

In "on search" non attendiamo il risultato della ricerca. Quando l'utente fornisce un input (ad esempio "Lon") è necessario deselezionare le opzioni precedenti. Ci aspettiamo che le opzioni siano vuote. Inoltre, l'input dell'utente deve essere limitato, diciamo per un valore di 300 millisecondi. In termini di zona, un microtask da 300 millis viene inserito nella coda.

Si noti che ometto alcuni dettagli per brevità:

  • la configurazione del test è praticamente la stessa di quella dei documenti angolari
  • l'istanza apiService viene iniettata tramite fixture.debugElement.injector (...)
  • SpecUtils attiva gli eventi relativi all'utente come input e focus
beforeEach (() => {
    spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult));
});
fit ('cancella il risultato precedente', fakeAsync (() => {
    comp.options = ['non vuoto'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    tick (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    prevedono (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]`);
}));

Il codice componente che prova a soddisfare il test:

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

Esaminiamo il codice passo dopo passo:

Spioniamo il metodo di query apiService che chiameremo nel componente. La variabile queryResult contiene alcuni dati simulati come "Amleto", "Macbeth" e "King Lear". All'inizio ci aspettiamo che le opzioni siano vuote, ma come avrai notato l'intera coda fakeAsync viene svuotata con tick (DEBOUNCING_VALUE) e quindi il componente contiene anche il risultato finale degli scritti di Shakespeare:

Previsto 3 come 0, "era [Amleto, Macbeth, Re Lear]".

È necessario un ritardo per la richiesta di query del servizio al fine di emulare un passaggio asincrono di tempo consumato dalla chiamata API. Aggiungiamo 5 secondi di ritardo (REQUEST_DELAY = 5000) e spuntiamo (5000).

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

fit ('cancella il risultato precedente', fakeAsync (() => {
    comp.options = ['non vuoto'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    tick (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    prevedono (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]`);
    tick (REQUEST_DELAY);
}));

Secondo me, questo esempio dovrebbe funzionare ma Zone.js afferma che c'è ancora del lavoro in coda:

Errore: 1 timer periodici ancora nella coda.

A questo punto dobbiamo andare più a fondo per vedere quelle funzioni che sospettiamo siano bloccate nella zona. L'impostazione di alcuni punti di interruzione è la strada da percorrere:

debug della zona fakeAsync

Quindi, emettere questo dalla riga di comando

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

o esaminare il contenuto della zona in questo modo:

hmmm, il metodo di flush di AsyncScheduler è ancora in coda ... perché?

Il nome della funzione accodata è il metodo flush di AsyncScheduler.

public flush (azione: AsyncAction ): void {
  const {actions} = questo;
  if (this.active) {
    actions.push (azione);
    ritorno;
  }
  let error: any;
  this.active = true;
  fare {
    if (errore = action.execute (action.state, action.delay)) {
      rompere;
    }
  } while (action = actions.shift ()); // esaurisce la coda dello scheduler
  this.active = false;
  if (errore) {
    while (action = actions.shift ()) {
      action.unsubscribe ();
    }
    errore di lancio;
  }
}

Ora, potresti chiederti cosa c'è che non va nel codice sorgente o nella stessa zona.

Il problema è che la zona e le nostre zecche non sono sincronizzate.

La zona stessa ha l'ora corrente (2017) mentre il segno di spunta vuole elaborare l'azione programmata il 01.01.1970 + 300 millis + 5 secondi.

Il valore dello schedulatore asincrono conferma che:

importare {asincrono come AsyncScheduler} da 'rxjs / scheduler / async';
// posizionalo da qualche parte all'interno del „it“
console.info (AsyncScheduler.now ());
// → 1503235213879

AsyncZoneTimeInSyncKeeper in soccorso

Una possibile soluzione per questo è avere un'utilità keep-in-sync come questa:

classe di esportazione AsyncZoneTimeInSyncKeeper {
    tempo = 0;
    constructor () {
        spyOn (AsyncScheduler, 'now'). and.callFake (() => {
            / * tslint: disable-next-line * /
            console.info ('time', this.time);
            restituire this.time;
        });
    }
    tick (tempo ?: ​​numero) {
        if (typeof time! == 'undefined') {
            this.time + = time;
            tick (this.time);
        } altro {
            tick ();
        }
    }
}

Tiene traccia dell'ora corrente che viene restituita da now () ogni volta che viene chiamato l'utilità di pianificazione asincrona. Questo funziona perché la funzione tick () utilizza lo stesso orario corrente. Sia lo scheduler che la zona condividono lo stesso tempo.

Consiglio di creare un'istanza di timeInSyncKeeper nella fase precedente Ogni fase:

descrivi ('on search', () => {
    lasciare timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
    });
});

Ora diamo un'occhiata all'utilizzo del custode della sincronizzazione temporale. Tieni presente che dobbiamo affrontare questo problema di temporizzazione perché il campo di testo è stato rimosso e la richiesta richiede del tempo.

descrivi ("alla ricerca", () => {
    lasciare timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
        spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult) .delay (REQUEST_DELAY));
    });
    it ('cancella il risultato precedente', fakeAsync (() => {
        comp.options = ['non vuoto'];
        SpecUtils.focusAndInput ('Lon', fixture, 'input');
        timeInSyncKeeper.tick (DEBOUNCING_VALUE);
        fixture.detectChanges ();
        prevedono (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]`);
        timeInSyncKeeper.tick (REQUEST_DELAY);
    }));
    // ...
});

Esaminiamo questo esempio riga per riga:

  1. istanza dell'istanza del keeper sincronizzato
timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();

2. lasciare rispondere il metodo apiService.query con il risultato queryResult dopo che REQUEST_DELAY è passato. Supponiamo che il metodo di query sia lento e risponda dopo REQUEST_DELAY = 5000 millisecondi.

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

3. Fai finta che nel campo del suggerimento sia presente un'opzione "non vuota"

comp.options = ['non vuoto'];

4. Vai al campo "input" nell'elemento nativo del dispositivo e inserisci il valore "Lon". Questo simula l'interazione dell'utente con il campo di input.

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

5. lascia passare il periodo di tempo DEBOUNCING_VALUE nella zona asincrona falsa (DEBOUNCING_VALUE = 300 millisecondi).

timeInSyncKeeper.tick (DEBOUNCING_VALUE);

6. Rileva le modifiche e visualizza nuovamente il modello HTML.

fixture.detectChanges ();

7. L'array di opzioni è vuoto ora!

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

Ciò significa che i cambi di valore osservabili utilizzati nei componenti sono stati gestiti per essere eseguiti al momento opportuno. Si noti che la funzione debounceTime-d eseguita

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

ha inserito un'altra attività nella coda chiamando il metodo suggest:

suggest (q: string) {
    if (! q) {
        ritorno;
    }
    this.googleBooksAPI.query (q) .subscribe (risultato => {
        if (risultato) {
            this.options = result.items.map (item => item.volumeInfo);
            this.onEvent.emit ({signal: SuggestSignal.success, totalItems: result.totalItems});
        } altro {
            this.onEvent.emit ({signal: SuggestSignal.success, totalItems: 0});
        }
    }, () => {
        this.onEvent.emit ({signal: SuggestSignal.error});
    });
}

Basta ricordare la spia sul metodo di query dell'API di google books che risponde dopo 5 secondi.

8. Infine, dobbiamo selezionare nuovamente REQUEST_DELAY = 5000 millisecondi per svuotare la coda delle zone. L'osservabile a cui ci iscriviamo nel metodo suggerito richiede REQUEST_DELAY = 5000 per il completamento.

timeInSyncKeeper.tick (REQUEST_DELAY);

fakeAsync ...? Perché? Ci sono programmatori!

Gli esperti di ReactiveX potrebbero obiettare che potremmo usare programmatori di test per rendere testabili gli osservabili. È possibile per applicazioni angolari ma presenta alcuni svantaggi:

  • richiede di acquisire familiarità con la struttura interna di osservabili, operatori, ...
  • cosa succede se hai qualche brutta soluzione setTimeout nella tua applicazione? Non sono gestiti dagli scheduler.
  • il più importante: sono sicuro che non vuoi usare gli scheduler in tutta la tua applicazione. Non vuoi mescolare il codice di produzione con i tuoi test unitari. Non vuoi fare qualcosa del genere:
const testScheduler;
if (environment.test) {
    testScheduler = new YourTestScheduler ();
}
lasciare osservabile;
if (testScheduler) {
    observable = Observable.of ("valore"). delay (1000, testScheduler)
} altro {
    observable = Observable.of ("valore"). delay (1000);
}

Questa non è una soluzione praticabile. A mio avviso, l'unica soluzione possibile è "iniettare" lo scheduler di test fornendo una sorta di "proxy" per i metodi Rxjs reali. Un'altra cosa da tenere in considerazione è che i metodi prioritari potrebbero influenzare negativamente i test unitari rimanenti. Ecco perché useremo le spie di Jasmine. Le spie vengono cancellate dopo ogni cosa.

La funzione monkeypatchScheduler avvolge l'implementazione originale di Rxjs usando una spia. La spia accetta gli argomenti del metodo e aggiunge testScheduler se appropriato.

importare {IScheduler} da 'rxjs / Scheduler';
importare {Observable} da 'rxjs / Observable';
dichiarare var spyOn: funzione;
funzione di esportazione monkeypatchScheduler (scheduler: IScheduler) {
    let observableMethods = ['concat', 'differire', 'vuoto', 'forkJoin', 'if', 'intervallo', 'unire', 'di', 'intervallo', 'lancio',
        'cerniera lampo'];
    let operatorMethods = ['buffer', 'concat', 'delay', 'distinguent', 'do', 'every', 'last', 'merge', 'max', 'take',
        'timeInterval', 'lift', 'debounceTime'];
    let injectFn = function (base: any, metodi: string []) {
        method.forEach (method => {
            const orig = base [metodo];
            if (typeof orig === 'function') {
                spyOn (base, metodo) .and.callFake (function () {
                    let args = Array.prototype.slice.call (argomenti);
                    if (args [args.length - 1] && typeof args [args.length - 1] .now === 'funzione') {
                        args [args.length - 1] = scheduler;
                    } altro {
                        args.push (scheduler);
                    }
                    return orig.apply (this, args);
                });
            }
        });
    };
    injectFn (Observable, observableMethods);
    injectFn (Observable.prototype, operatorMethods);
}

D'ora in poi, testScheduler eseguirà tutto il lavoro all'interno di Rxjs. Non utilizza setTimeout / setInterval o alcun tipo di roba asincrona. Non è più necessario per fakeAsync.

Ora, abbiamo bisogno di un'istanza dello scheduler di test che vogliamo passare a monkeypatchScheduler.

Si comporta in modo molto simile al TestScheduler predefinito ma fornisce un metodo di callback su Action. In questo modo, sappiamo quale azione è stata eseguita dopo un determinato periodo di tempo.

classe di esportazione SpyingTestScheduler estende VirtualTimeScheduler {
    spyFn: (actionName: string, delay: number, error ?: any) => void;
    constructor () {
        super (VirtualAction, defaultMaxFrame);
    }
    onAction (spyFn: (actionName: string, delay: number, error ?: any) => void) {
        this.spyFn = spyFn;
    }
    sciacquone() {
        const {actions, maxFrames} = this;
        let error: any, action: AsyncAction ;
        while ((action = actions.shift ()) && (this.frame = action.delay) <= maxFrames) {
            let stateName = this.detectStateName (azione);
            let delay = action.delay;
            if (errore = action.execute (action.state, action.delay)) {
                if (this.spyFn) {
                    this.spyFn (stateName, delay, error);
                }
                rompere;
            } altro {
                if (this.spyFn) {
                    this.spyFn (stateName, delay);
                }
            }
        }
        if (errore) {
            while (action = actions.shift ()) {
                action.unsubscribe ();
            }
            errore di lancio;
        }
    }
    private detectStateName (azione: AsyncAction ): string {
        const c = Object.getPrototypeOf (action.state) .constructor;
        const argsPos = c.toString (). indexOf ('(');
        if (argsPos! == -1) {
            return c.toString (). substring (9, argsPos);
        }
        restituisce null;
    }
}

Infine, diamo un'occhiata all'utilizzo. L'esempio è lo stesso test unitario usato in precedenza (("cancella il risultato precedente") con la leggera differenza che useremo lo scheduler di test invece di fakeAsync / tick.

lascia testScheduler;
beforeEach (() => {
    testScheduler = new SpyingTestScheduler ();
    testScheduler.maxFrames = 1000000;
    monkeypatchScheduler (testScheduler);
    fixture.detectChanges ();
});
beforeEach (() => {
    spyOn (apiService, 'query'). and.callFake (() => {
        return Observable.of (queryResult) .delay (REQUEST_DELAY);
    });
});
it ('cancella il risultato precedente', (done: Function) => {
    comp.options = ['non vuoto'];
    testScheduler.onAction ((actionName: string, delay: number, err ?: any) => {
        if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
            prevedono (comp.options.length) .toBe (0, `era [$ {comp.options.join (',')}]`);
            fatto();
        }
    });
    SpecUtils.focusAndInput ('Londo', fixture, 'input');
    fixture.detectChanges ();
    testScheduler.flush ();
});

Lo scheduler di test viene creato e monkeypatched (!) Nel primo prima di ogni operazione. Nel secondo prima di ogni, abbiamo spiato apiService.query per servire il risultato queryResult dopo REQUEST_DELAY = 5000 millisecondi.

Ora, esaminiamo la riga per riga:

  1. Prima di tutto, tieni presente che dichiariamo la funzione completata di cui abbiamo bisogno insieme al callback del programmatore di test su Action. Ciò significa che dobbiamo dire a Jasmine che il test viene eseguito da soli.
it ('cancella il risultato precedente', (done: Function) => {

2. Ancora una volta, facciamo finta di avere alcune opzioni presenti nel componente.

comp.options = ['non vuoto'];

3. Ciò richiede alcune spiegazioni perché a prima vista sembra essere un po 'goffo. Vogliamo attendere un'azione chiamata "DebounceTimeSubscriber" con un ritardo di DEBOUNCING_VALUE = 300 millisecondi. Quando ciò accade, vogliamo verificare se options.length è 0. Quindi, il test è completato e chiamiamo done ().

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

Si vede che l'utilizzo di programmatori di test richiede alcune conoscenze speciali sugli interni di implementazione di Rxjs. Ovviamente dipende dal programma di test che si utilizza, ma anche se si implementa un programma di pianificazione potente da solo, è necessario comprendere gli scheduler ed esporre alcuni valori di runtime per flessibilità (che, di nuovo, potrebbe non essere autoesplicativo).

4. Ancora una volta, l'utente inserisce il valore "Londo".

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

5. Ancora una volta, rileva le modifiche e ridigita il modello.

fixture.detectChanges ();

6. Infine, eseguiamo tutte le azioni inserite nella coda dello scheduler.

testScheduler.flush ();

Sommario

Le utilità di test di Angular sono preferibili a quelle fatte da sé ... purché funzionino. In alcuni casi la coppia fakeAsync / tick non funziona ma non c'è motivo di disperare e omettere i test unitari. In questi casi, un'utilità di sincronizzazione automatica (qui nota anche come AsyncZoneTimeInSyncKeeper) o uno scheduler di test personalizzato (qui noto anche come SpyingTestScheduler) è la strada da percorrere.

Codice sorgente