Angular: prueba de material asíncrono en la zona de sincronización falsificada VS. proporcionando programadores personalizados

Muchas veces me han hecho preguntas sobre la "zona falsa" y cómo usarla. Es por eso que decidí escribir este artículo para compartir mis observaciones cuando se trata de pruebas detalladas de "fakeAsync".

La zona es una parte crucial del ecosistema angular. Uno podría haber leído que la zona en sí misma es solo una especie de "contexto de ejecución". De hecho, Angular monkey parchea las funciones globales como setTimeout o setInterval para interceptar funciones que se ejecutan después de algún retraso (setTimeout) o periódicamente (setInterval).

Es importante mencionar que este artículo no mostrará cómo lidiar con los hacks setTimeout. Dado que Angular hace un uso intensivo de los RxJ que dependen de las funciones de temporización nativas (puede que se sorprenda, pero es cierto), utiliza zone como una herramienta compleja pero poderosa para registrar todas las acciones asincrónicas que pueden afectar el estado de la aplicación. Angular los intercepta para saber si todavía hay algo de trabajo en la cola. Drena la cola dependiendo del tiempo. Lo más probable es que las tareas drenadas cambien los valores de las variables componentes. Como resultado, la plantilla se vuelve a representar.

Ahora, todo lo asíncrono no es de lo que debemos preocuparnos. Es agradable comprender lo que sucede debajo del capó porque ayuda a escribir pruebas unitarias efectivas. Además, el desarrollo impulsado por pruebas tiene un gran impacto en el código fuente (los orígenes de TDD fueron el deseo de obtener pruebas de regresión automáticas sólidas que respaldaran el diseño evolutivo. En el camino, sus profesionales descubrieron que las pruebas escritas primero hicieron una mejora significativa en el proceso de diseño. "Martin Fowler, https://martinfowler.com/articles/mocksArentStubs.html, 09/2017).

Como resultado de todos estos esfuerzos, podemos cambiar el tiempo ya que necesitamos evaluar el estado en un punto específico en el tiempo.

fakeAsync / tick outline

Los documentos de Angular indican que fakeAsync (https://angular.io/guide/testing#fake-async) ofrece una experiencia de codificación más lineal porque se deshace de promesas como .whenStable (). Luego (...).

El código dentro del bloque fakeAsync se ve así:

garrapata (100); // espera a que se complete la primera tarea
fixture.detectChanges (); // actualizar vista con cita
garrapata(); // espera a que termine la segunda tarea
fixture.detectChanges (); // actualizar vista con cita

Los siguientes fragmentos proporcionan información sobre cómo funciona fakeAsync.

Aquí se usan setTimeout / setInterval porque muestran claramente cuándo se ejecutan las funciones en la zona fakeAsync. Puede esperar que esta función "it" tenga que saber cuándo se realiza la prueba (en Jasmine organizada por el argumento done: Function), pero esta vez confiamos en el compañero fakeAsync en lugar de utilizar cualquier tipo de devolución de llamada:

it ('drena la zona tarea por tarea', fakeAsync (() => {
        setTimeout (() => {
            dejar i = 0;
            const handle = setInterval (() => {
                if (i ++ === 5) {
                    clearInterval (manejador);
                }
            }, 1000);
        }, 10000);
}));

Se queja en voz alta porque todavía hay algunos "temporizadores" (= setTimeouts) en la cola:

Error: 1 temporizador (s) todavía en la cola.

Es obvio que necesitamos cambiar el tiempo para realizar la función timeouted. Anexamos la "marca" parametrizada con 10 segundos:

garrapata (10000);

Hugh El error se vuelve más confuso. Ahora, la prueba falla debido a los "temporizadores periódicos" (= setIntervals) en cola:

Error: 1 temporizador (es) periódico (s) todavía en la cola.

Dado que pusimos en cola una función que debe ejecutarse cada segundo, también necesitamos cambiar el tiempo usando el tic nuevamente. La función termina después de 5 segundos. Es por eso que necesitamos agregar otros 5 segundos:

garrapata (15000);

Ahora, la prueba está pasando. Vale la pena decir que la zona reconoce las tareas que se ejecutan en paralelo. Simplemente extienda la función timeouted por otra llamada setInterval.

it ('drena la zona tarea por tarea', fakeAsync (() => {
    setTimeout (() => {
        dejar i = 0;
        const handle = setInterval (() => {
            si (++ i === 5) {
                clearInterval (manejador);
            }
        }, 1000);
        dejar j = 0;
        const handle2 = setInterval (() => {
            if (++ j === 3) {
                clearInterval (handle2);
            }
        }, 1000);
    }, 10000);
    garrapata (15000);
}));

La prueba todavía está pasando porque ambos setIntervals se han iniciado en el mismo momento. Ambos se hacen cuando pasan 15 segundos:

fakeAsync / tick en acción

Ahora sabemos cómo funciona fakeAsync / tick. Déjalo usar para algunas cosas significativas.

Desarrollemos un campo de sugerencia que cumpla estos requisitos:

  • toma el resultado de alguna API (servicio)
  • acelera la entrada del usuario para esperar el término de búsqueda final (disminuye el número de solicitudes); DEBOUNCING_VALUE = 300
  • muestra el resultado en la interfaz de usuario y emite el mensaje apropiado
  • la prueba unitaria respeta la naturaleza asíncrona del código y prueba el comportamiento adecuado del campo de sugerencia en términos del tiempo transcurrido

Terminamos con estos escenarios de prueba:

describe ('en búsqueda', () => {
    it ('borra el resultado anterior', fakeAsync (() => {
    }));
    it ('emite la señal de inicio', fakeAsync (() => {
    }));
    it ('está acelerando las posibles visitas de la API a 1 solicitud por DEBOUNCING_VALUE milisegundos', fakeAsync (() => {
    }));
});
describe ('en caso de éxito', () => {
    it ('llama a la API de google', fakeAsync (() => {
    }));
    it ('emite la señal de éxito con número de coincidencias', fakeAsync (() => {
    }));
    it ('muestra los títulos en el campo de sugerencias', fakeAsync (() => {
    }));
});
describe ('en caso de error', () => {
    it ('emite la señal de error', fakeAsync (() => {
    }));
});

En "en búsqueda" no esperamos el resultado de la búsqueda. Cuando el usuario proporciona una entrada (por ejemplo, "Lon"), las opciones anteriores deben borrarse. Esperamos que las opciones estén vacías. Además, la entrada del usuario debe ser acelerada, digamos por un valor de 300 milisegundos. En términos de la zona, se empuja una cola de 300 milis micras.

Tenga en cuenta que omito algunos detalles por brevedad:

  • la configuración de prueba es más o menos la misma que se ve en los documentos angulares
  • la instancia de apiService se inyecta a través de fixture.debugElement.injector (...)
  • SpecUtils activa los eventos relacionados con el usuario, como la entrada y el enfoque
beforeEach (() => {
    spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult));
});
fit ('borra el resultado anterior', fakeAsync (() => {
    comp.options = ['no vacío'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    marca (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
}));

El código del componente que intenta satisfacer la prueba:

ngOnInit () {
    this.control.valueChanges.debounceTime (300) .subscribe (valor => {
        this.options = [];
        this.suggest (valor);
    });
}
sugerir (q: cadena) {
    this.googleBooksAPI.query (q) .subscribe (resultado => {
// ...
    }, () => {
// ...
    });
}

Veamos el código paso a paso:

Espiamos el método de consulta apiService al que vamos a llamar en el componente. La variable queryResult contiene algunos datos simulados como "Hamlet", "Macbeth" y "King Lear". Al principio, esperamos que las opciones estén vacías, pero como habrás notado, toda la cola de fakeAsync se vacía con la marca (DEBOUNCING_VALUE) y, por lo tanto, el componente contiene el resultado final de los escritos de Shakespeare:

Se esperaba que 3 fuera 0, "fue [Hamlet, Macbeth, King Lear]".

Necesitamos un retraso para la solicitud de consulta de servicio para emular un paso de tiempo asincrónico consumido por la llamada API. Agreguemos 5 segundos de retraso (REQUEST_DELAY = 5000) y marque (5000).

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

fit ('borra el resultado anterior', fakeAsync (() => {
    comp.options = ['no vacío'];
    SpecUtils.focusAndInput ('Lon', fixture, 'input');
    marca (DEBOUNCING_VALUE);
    fixture.detectChanges ();
    expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
    marca (REQUEST_DELAY);
}));

En mi opinión, este ejemplo debería funcionar, pero Zone.js afirma que todavía hay algo de trabajo en la cola:

Error: 1 temporizador (es) periódico (s) todavía en la cola.

En este punto, debemos profundizar para ver aquellas funciones que sospechamos que se quedan atascadas en la zona. Establecer algunos puntos de interrupción es el camino a seguir:

depuración de la zona falsa de sincronización

Luego, emita esto en la línea de comando

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

o examine el contenido de la zona así:

hmmm, el método de descarga de AsyncScheduler todavía está en la cola ... ¿por qué?

El nombre de la función en cola es el método de descarga de AsyncScheduler.

vaciado público (acción: AsyncAction ): void {
  const {acciones} = esto;
  if (this.active) {
    actions.push (acción);
    regreso;
  }
  dejar error: cualquiera;
  this.active = true;
  hacer {
    if (error = action.execute (action.state, action.delay)) {
      rotura;
    }
  } while (action = actions.shift ()); // agotar la cola del planificador
  this.active = false;
  if (error) {
    while (action = actions.shift ()) {
      action.unsubscribe ();
    }
    error de lanzamiento;
  }
}

Ahora, puede preguntarse qué hay de malo con el código fuente o la zona misma.

El problema es que la zona y nuestras garrapatas no están sincronizadas.

La zona en sí tiene la hora actual (2017), mientras que la marca quiere procesar la acción programada para el 01.01.1970 + 300 milis + 5 segundos.

El valor del planificador asíncrono confirma que:

importar {async como AsyncScheduler} desde 'rxjs / Scheduler / async';
// coloca esto en algún lugar dentro del „it“
console.info (AsyncScheduler.now ());
// → 1503235213879

AsyncZoneTimeInSyncKeeper al rescate

Una posible solución para esto es tener una utilidad para mantener la sincronización como esta:

clase de exportación AsyncZoneTimeInSyncKeeper {
    tiempo = 0;
    constructor () {
        spyOn (AsyncScheduler, 'ahora'). and.callFake (() => {
            / * tslint: disable-next-line * /
            console.info ('hora', this.time);
            devuelve this.time;
        });
    }
    marca (hora ?: número) {
        if (typeof time! == 'undefined') {
            this.time + = tiempo;
            marca (this.time);
        } más {
            garrapata();
        }
    }
}

Realiza un seguimiento de la hora actual que devuelve el now () cada vez que se llama al planificador asíncrono. Esto funciona porque la función tick () usa la misma hora actual. Tanto el planificador como la zona comparten el mismo tiempo.

Recomiendo instanciar el timeInSyncKeeper en la fase beforeEach:

describe ('en búsqueda', () => {
    deja que timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
    });
});

Ahora, echemos un vistazo al uso del controlador de sincronización de tiempo. Tenga en cuenta que tenemos que abordar este problema de temporización porque el campo de texto se elimina y la solicitud tarda un poco.

describe ("en la búsqueda", () => {
    deja que timeInSyncKeeper;
    beforeEach (() => {
        timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();
        spyOn (apiService, 'query'). and.returnValue (Observable.of (queryResult) .delay (REQUEST_DELAY));
    });
    it ('borra el resultado anterior', fakeAsync (() => {
        comp.options = ['no vacío'];
        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);
    }));
    // ...
});

Veamos este ejemplo línea por línea:

  1. instanciar la instancia del guardián sincronizado
timeInSyncKeeper = new AsyncZoneTimeInSyncKeeper ();

2. deje que responda el método apiService.query con el resultado queryResult después de que REQUEST_DELAY haya pasado. Digamos que el método de consulta es lento y responde después de REQUEST_DELAY = 5000 milisegundos.

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

3. Imagine que hay una opción, no vacía, presente en el campo de sugerencia

comp.options = ['no vacío'];

4. Vaya al campo "entrada" en el elemento nativo del dispositivo e inserte el valor "Lon". Esto simula la interacción del usuario con el campo de entrada.

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

5. deje pasar el período de tiempo DEBOUNCING_VALUE en la zona asíncrona falsa (DEBOUNCING_VALUE = 300 milisegundos).

timeInSyncKeeper.tick (DEBOUNCING_VALUE);

6. Detecte cambios y vuelva a representar la plantilla HTML.

fixture.detectChanges ();

7. ¡La matriz de opciones está vacía ahora!

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

Esto significa que los cambios de valor observables utilizados en los componentes se gestionaron para ejecutarse en el momento adecuado. Tenga en cuenta que la función debounceTime-d ejecutada

valor => {
    this.options = [];
    this.onEvent.emit ({señal: CalculateSignal.start});
    this.suggest (valor);
}

empujó otra tarea a la cola llamando al método sugiere:

sugerir (q: cadena) {
    si (! q) {
        regreso;
    }
    this.googleBooksAPI.query (q) .subscribe (resultado => {
        if (resultado) {
            this.options = result.items.map (item => item.volumeInfo);
            this.onEvent.emit ({señal: CalculateSignal.success, totalItems: result.totalItems});
        } más {
            this.onEvent.emit ({señal: CalculateSignal.success, totalItems: 0});
        }
    }, () => {
        this.onEvent.emit ({señal: SugerirSignal.error});
    });
}

Solo recuerda el espía en el método de consulta API de google books que responde después de 5 segundos.

8. Finalmente, tenemos que marcar nuevamente REQUEST_DELAY = 5000 milisegundos para limpiar la cola de la zona. El observable al que nos suscribimos en el método de sugerencia necesita REQUEST_DELAY = 5000 para completarse.

timeInSyncKeeper.tick (REQUEST_DELAY);

fakeAsync ...? ¿Por qué? Hay planificadores!

Los expertos de ReactiveX podrían argumentar que podríamos usar programadores de prueba para hacer comprobables los observables. Es posible para aplicaciones angulares pero tiene algunas desventajas:

  • requiere que te familiarices con la estructura interna de observables, operadores, ...
  • ¿Qué pasa si tiene algunas soluciones alternativas setTimeout feas en su aplicación? No son manejados por los planificadores.
  • el más importante: estoy seguro de que no desea utilizar programadores en toda su aplicación. No desea mezclar el código de producción con sus pruebas unitarias. No quieres hacer algo como esto:
const testScheduler;
if (environment.test) {
    testScheduler = new YourTestScheduler ();
}
dejar observable
if (testScheduler) {
    observable = Observable.of ("valor"). delay (1000, testScheduler)
} más {
    observable = Observable.of ("valor"). delay (1000);
}

Esta no es una solución viable. En mi opinión, la única solución posible es "inyectar" el planificador de pruebas proporcionando una especie de "proxies" para los métodos Rxjs reales. Otra cosa a tener en cuenta es que los métodos de anulación podrían influir negativamente en las pruebas unitarias restantes. Por eso vamos a usar los espías de Jasmine. Los espías se aclaran después de todo.

La función monkeypatchScheduler envuelve la implementación original de Rxjs mediante el uso de un espía. El espía toma los argumentos del método y agrega testScheduler si corresponde.

importar {IScheduler} desde 'rxjs / Scheduler';
import {Observable} desde 'rxjs / Observable';
declarar var spyOn: Function;
función de exportación monkeypatchScheduler (planificador: IScheduler) {
    let observableMethods = ['concat', 'defer', 'empty', 'forkJoin', 'if', 'interval', 'merge', 'of', 'range', 'throw',
        'Código Postal'];
    let operatorMethods = ['buffer', 'concat', 'delay', 'distinct', 'do', 'every', 'last', 'merge', 'max', 'take',
        'timeInterval', 'lift', 'debounceTime'];
    let injectFn = function (base: any, métodos: string []) {
        method.forEach (method => {
            const orig = base [método];
            if (typeof orig === 'función') {
                spyOn (base, método) .and.callFake (function () {
                    let args = Array.prototype.slice.call (argumentos);
                    if (args [args.length - 1] && typeof args [args.length - 1] .now === 'función') {
                        args [args.length - 1] = planificador;
                    } más {
                        args.push (planificador);
                    }
                    return orig.apply (esto, args);
                });
            }
        });
    };
    injectFn (Observable, observableMethods);
    injectFn (Observable.prototype, operatorMethods);
}

A partir de ahora, testScheduler ejecutará todo el trabajo dentro de Rxjs. No utiliza setTimeout / setInterval ni ningún tipo de material asíncrono. Ya no hay necesidad de fakeAsync.

Ahora, necesitamos una instancia del planificador de prueba que queremos pasar a monkeypatchScheduler.

Se comporta de manera muy similar al TestScheduler predeterminado, pero proporciona un método de devolución de llamada en Action. De esta manera, sabemos qué acción se ejecutó después de qué período de tiempo.

clase de exportación SpyingTestScheduler extiende 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;
    }
    flush () {
        const {actions, maxFrames} = esto;
        error de let: any, action: AsyncAction ;
        while ((action = actions.shift ()) && (this.frame = action.delay) <= maxFrames) {
            deje stateName = this.detectStateName (acción);
            let delay = action.delay;
            if (error = action.execute (action.state, action.delay)) {
                if (this.spyFn) {
                    this.spyFn (stateName, retraso, error);
                }
                rotura;
            } más {
                if (this.spyFn) {
                    this.spyFn (stateName, delay);
                }
            }
        }
        if (error) {
            while (action = actions.shift ()) {
                action.unsubscribe ();
            }
            error de lanzamiento;
        }
    }
    private detectStateName (acción: AsyncAction ): string {
        const c = Object.getPrototypeOf (action.state) .constructor;
        const argsPos = c.toString (). indexOf ('(');
        if (argsPos! == -1) {
            return c.toString (). substring (9, argsPos);
        }
        volver nulo;
    }
}

Finalmente, echemos un vistazo al uso. El ejemplo es la misma prueba unitaria que se usó anteriormente (("borra el resultado anterior") con la ligera diferencia de que vamos a usar el programador de prueba en lugar de fakeAsync / tick.

dejar testScheduler;
beforeEach (() => {
    testScheduler = nuevo SpyingTestScheduler ();
    testScheduler.maxFrames = 1000000;
    monkeypatchScheduler (testScheduler);
    fixture.detectChanges ();
});
beforeEach (() => {
    spyOn (apiService, 'query'). and.callFake (() => {
        return Observable.of (queryResult) .delay (REQUEST_DELAY);
    });
});
it ('borra el resultado anterior', (done: Función) => {
    comp.options = ['no vacío'];
    testScheduler.onAction ((actionName: string, delay: number, err ?: any) => {
        if (actionName === 'DebounceTimeSubscriber' && delay === DEBOUNCING_VALUE) {
            expect (comp.options.length) .toBe (0, `was [$ {comp.options.join (',')}]`);
            hecho();
        }
    });
    SpecUtils.focusAndInput ('Londo', fixture, 'input');
    fixture.detectChanges ();
    testScheduler.flush ();
});

El planificador de prueba se crea y se aplica parche (!) En el primero antes de Cada. En el segundo antes de Cada, espiamos apiService.query para servir el resultado queryResult después de REQUEST_DELAY = 5000 milisegundos.

Ahora, veamos la línea por línea:

  1. En primer lugar, tenga en cuenta que declaramos la función terminada que necesitamos junto con la devolución de llamada del planificador de pruebas onAction. Esto significa que debemos decirle a Jasmine que la prueba se realiza por nuestra cuenta.
it ('borra el resultado anterior', (done: Función) => {

2. Nuevamente, pretendemos algunas opciones presentes en el componente.

comp.options = ['no vacío'];

3. Esto requiere alguna explicación porque parece ser un poco torpe a primera vista. Queremos esperar una acción llamada "DebounceTimeSubscriber" con un retraso de DEBOUNCING_VALUE = 300 milisegundos. Cuando esto sucede, queremos verificar si options.length es 0. Luego, la prueba se completa y llamamos a 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 (',')}]`);
      hecho();
    }
});

Verá que el uso de planificadores de prueba requiere un conocimiento especial sobre los aspectos internos de implementación de Rxjs. Por supuesto, depende del planificador de prueba que utilice, pero incluso si implementa un planificador potente por su cuenta, deberá comprender los planificadores y exponer algunos valores de tiempo de ejecución para mayor flexibilidad (que, de nuevo, podría no explicarse por sí mismo).

4. Nuevamente, el usuario ingresa el valor "Londo".

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

5. Nuevamente, detecte cambios y vuelva a renderizar la plantilla.

fixture.detectChanges ();

6. Finalmente, ejecutamos todas las acciones colocadas en la cola del planificador.

testScheduler.flush ();

Resumen

Las propias utilidades de prueba de Angular son preferibles a las que se hacen a sí mismas ... siempre que funcionen. En algunos casos, fakeAsync / tick couple no funciona, pero no hay razón para desesperarse y omitir las pruebas unitarias. En estos casos, una utilidad de sincronización automática (aquí también conocida como AsyncZoneTimeInSyncKeeper) o un programador de prueba personalizado (aquí también conocido como SpyingTestScheduler) es el camino a seguir.

Código fuente