Testing en Javascript con JEST. Parte 2 de 2

Testing asíncrono

Cuando necesitamos realizar test con funciones asíncronas jest nos ofrece varias opciones, usando callbacks, promesas y async / await.

Callbacks

Por su facilidad de uso empezaremos realizando unos test usando callbacks.

index.js

const provincias = ['Álava','Badajoz','Cáceres','Girona','Huelva','Jaén','La Rioja','Madrid','Navarra'];
const dias = ['Lunes','Martes','Miercoles','Jueves','Viernes','Sabado','Domingo'];
const expReg = {
    responseOK: 'Response OK',
    responseFAIL: 'Response FAIL',
    email: 'test@test.com',
    telefono: '919784852'
}
export const sumar = (a, b) => a + b;
export const restar = (a, b) => a - b;
export const multiplicar = (a, b) => a * b;
export const dividir = (a, b) => a / b;
export const isNull = () => null;
export const isFalse = () => false;
export const isTrue = () => true;
export const isUndefined = () => undefined;
export const arrProvincias = () => provincias;
export const arrDias = () => dias;
export const objExpReg = () => expReg;
export const callback = callback => setTimeout(() => callback('Hola mundo callback'), 3000) ;

index.test.js

import { sumar, restar, multiplicar, dividir } from '../index';
import { isFalse, isNull, isTrue, isUndefined } from '../index';
import { arrDias, arrProvincias, objExpReg } from '../index.js';
import { callback } from '../index';

describe('Operaciones matemáticas', () => {
    test('Realizamos la suma', () => {
        expect(sumar(1,1)).toBe(2);
    });
    test('Realizamos la resta', () => {
        expect(restar(1,1)).toBe(0);
    });
    test('Realizamos la multiplicacion', () => {
        expect(multiplicar(1,1)).toBe(1);
    });
    test('Realizamos la division', () => {
        expect(dividir(1,1)).toBe(1);
    });
});
describe('Common matchers', () => {
    const datos = {
        nombre: 'Persona 1',
        edad: 10
    }
    const datos2 = {
        nombre: 'Persona 1',
        edad: 10
    }
    test('Comprobamos que los objectos son iguales', () => {
        expect(datos).toEqual(datos2);
    });
});
describe('Numeric matchers', () => {
    test('Resultado menor que...', () => {
        expect(restar(5,3)).toBeLessThan(3);
    });
    test('Resultado menor o igual que...', () => {
        expect(restar(5,3)).toBeLessThanOrEqual(2);
    });
    test('Resultado mayor o igual que...', () => {
        expect(multiplicar(2,5)).toBeGreaterThanOrEqual(10);
    });
    test('Resultado mayor que...', () => {
        expect(sumar(5,5)).toBeGreaterThan(9);
    });
});
describe('Matchers Boolean, Undefined o Null', () => {
    test('Resultado true', () => {
        expect(isTrue()).toBeTruthy();
    });
    test('Resultado false', () => {
        expect(isFalse()).toBeFalsy();
    });
    test('Resultado undefined', () => {
        expect(isUndefined()).toBeUndefined();
    });
    test('Resultado null', () => {
        expect(isNull()).toBeNull();
    });
});
describe('Matchers Arrays', () => { 
    test('Madrid existe en el array', () => {
        expect(arrProvincias()).toContain('Madrid');
    });
    test('Guadalajara no existe en el array', () => {
        expect(arrProvincias()).not.toContain('Guadalajara');
    });
    test('El array semana tiene 9 elementos', () => {
        expect(arrProvincias()).toHaveLength(9);
    });
    test('Existe Lunes en el array semana', () => {
        expect(arrDias()).toContain('Lunes');
    });
});
describe('Matchers Strings', () => {
    const exp = objExpReg();
    test('Comprobamos si la respuesta es correcta', () => {
        expect(exp.responseOK).toMatch(/OK/);
    });
    test('Comprobamos si la respuesta es incorrecta', () => {
        expect(exp.responseFAIL).toMatch(/FAIL/);
    });
    test('Comprobamos si la respuesta tiene una longitud', () => {
        expect(exp.responseFAIL).toHaveLength(13);
    });
    test('Comprobamos dirección de email', () => {
        expect(exp.email).toMatch(/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.([a-zA-Z]{2,4})+$/);
    })
    test('Comprobamos número de teléfono', () => {
        expect(exp.telefono).toMatch(/^[9|6|7][0-9]{8}$/);
    });
});
afterEach(() => console.log('Despues de cada prueba'));
afterAll(() => console.log("Despues de todas las pruebas"));
beforeEach(() => console.log('Antes de cada prueba'));
beforeAll(() => console.log('Antes de todas las pruebas'));
describe('Asincrono - Callback', () => {
    test('Callback', done => {
        let callbackInterno = datos => {
            expect(datos).toBe('Hola mundo callback');
            done();
        };
        callback(callbackInterno);
    })
});

Como hemos podido ver en el test, estamos enviando como parámetro el argumento done. Este argumento se usa para que jest sepa que hemos terminado y debemos añadirlo al final del test para que no falle.

La siguiente prueba de test asíncronos la vamos a realizar usando promesas.

Promise

Para esta prueba he usado el paquete de npm llamado JSON Server y así crear una API REST en nuestro servidor local.

Para iniciar nuestro servidor de JSON usamos el siguiente comando: json-server db.json

db.json

{
    "posts": [{ "id": 1, "title": "json-server", "author": "typicode" }],
    "comments": [{ "id": 1, "body": "some comment", "postId": 1 }],
    "profile": { "name": "typicode" }
}

index.js

const provincias = ['Álava','Badajoz','Cáceres','Girona','Huelva','Jaén','La Rioja','Madrid','Navarra'];
const dias = ['Lunes','Martes','Miercoles','Jueves','Viernes','Sabado','Domingo'];
const expReg = {
    responseOK: 'Response OK',
    responseFAIL: 'Response FAIL',
    email: 'test@test.com',
    telefono: '919784852'
}
export const sumar = (a, b) => a + b;
export const restar = (a, b) => a - b;
export const multiplicar = (a, b) => a * b;
export const dividir = (a, b) => a / b;
export const isNull = () => null;
export const isFalse = () => false;
export const isTrue = () => true;
export const isUndefined = () => undefined;
export const arrProvincias = () => provincias;
export const arrDias = () => dias;
export const objExpReg = () => expReg;
export const callback = callback => setTimeout(() => callback('Hola mundo callback'), 3000);
export const ajaxGet = url => {
    return new Promise((resolve, reject) => {
        let req = new XMLHttpRequest();
        req.open("GET", url);
        req.onload = () => req.status === 200 ? resolve(JSON.parse(req.response)) : reject(req.statusText);
        req.onerror = () => reject(req.statusText);
        req.send();
    });
};

index.test.js

import { sumar, restar, multiplicar, dividir } from '../index';
import { isFalse, isNull, isTrue, isUndefined } from '../index';
import { arrDias, arrProvincias, objExpReg } from '../index.js';
import { callback, ajaxGet } from '../index';

describe('Operaciones matemáticas', () => {
    test('Realizamos la suma', () => {
        expect(sumar(1,1)).toBe(2);
    });
    test('Realizamos la resta', () => {
        expect(restar(1,1)).toBe(0);
    });
    test('Realizamos la multiplicacion', () => {
        expect(multiplicar(1,1)).toBe(1);
    });
    test('Realizamos la division', () => {
        expect(dividir(1,1)).toBe(1);
    });
});
describe('Common matchers', () => {
    const datos = {
        nombre: 'Persona 1',
        edad: 10
    }
    const datos2 = {
        nombre: 'Persona 1',
        edad: 10
    }
    test('Comprobamos que los objectos son iguales', () => {
        expect(datos).toEqual(datos2);
    });
});
describe('Numeric matchers', () => {
    test('Resultado menor que...', () => {
        expect(restar(5,3)).toBeLessThan(3);
    });
    test('Resultado menor o igual que...', () => {
        expect(restar(5,3)).toBeLessThanOrEqual(2);
    });
    test('Resultado mayor o igual que...', () => {
        expect(multiplicar(2,5)).toBeGreaterThanOrEqual(10);
    });
    test('Resultado mayor que...', () => {
        expect(sumar(5,5)).toBeGreaterThan(9);
    });
});
describe('Matchers Boolean, Undefined o Null', () => {
    test('Resultado true', () => {
        expect(isTrue()).toBeTruthy();
    });
    test('Resultado false', () => {
        expect(isFalse()).toBeFalsy();
    });
    test('Resultado undefined', () => {
        expect(isUndefined()).toBeUndefined();
    });
    test('Resultado null', () => {
        expect(isNull()).toBeNull();
    });
});
describe('Matchers Arrays', () => { 
    test('Madrid existe en el array', () => {
        expect(arrProvincias()).toContain('Madrid');
    });
    test('Guadalajara no existe en el array', () => {
        expect(arrProvincias()).not.toContain('Guadalajara');
    });
    test('El array semana tiene 9 elementos', () => {
        expect(arrProvincias()).toHaveLength(9);
    });
    test('Existe Lunes en el array semana', () => {
        expect(arrDias()).toContain('Lunes');
    });
});
describe('Matchers Strings', () => {
    const exp = objExpReg();
    test('Comprobamos si la respuesta es correcta', () => {
        expect(exp.responseOK).toMatch(/OK/);
    });
    test('Comprobamos si la respuesta es incorrecta', () => {
        expect(exp.responseFAIL).toMatch(/FAIL/);
    });
    test('Comprobamos si la respuesta tiene una longitud', () => {
        expect(exp.responseFAIL).toHaveLength(13);
    });
    test('Comprobamos dirección de email', () => {
        expect(exp.email).toMatch(/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.([a-zA-Z]{2,4})+$/);
    })
    test('Comprobamos número de teléfono', () => {
        expect(exp.telefono).toMatch(/^[9|6|7][0-9]{8}$/);
    });
});
afterEach(() => console.log('Despues de cada prueba'));
afterAll(() => console.log("Despues de todas las pruebas"));
beforeEach(() => console.log('Antes de cada prueba'));
beforeAll(() => console.log('Antes de todas las pruebas'));
describe('Asincrono - Callback', () => {
    test('Callback', done => {
        let callbackInterno = datos => {
            expect(datos).toBe('Hola mundo callback');
            done();
        };
        callback(callbackInterno);
    })
});
describe('Asincrono - Promise(resolve, reject)', () => {
    test('Promise - Promise(resolve, reject)', done => {
        let url = "http://localhost:3000/posts";
        ajaxGet(url).then(datos => {
            const data = [{ "id": 1, "title": "json-server", "author": "typicode" }];
            expect(datos.length).toBeGreaterThanOrEqual(1);
            expect(datos[0].id).toBeGreaterThanOrEqual(1);
            expect(datos).toEqual(data);
            done();
        });
    });
    test('Promise - .resolves', () => {
        let url = "http://localhost:3000/profile";
        return expect(ajaxGet(url)).resolves.toEqual({"name": "typicode"});
    });
    test('Promise - .rejects', () => {
        let url = "http://localhost:3000/fail";
        return expect(ajaxGet(url)).rejects.toEqual('Not Found');
    });
    test('Promise - Promise.resolve', () => {
        let data = { nombre: 'Test', estado: true };
        return expect(Promise.resolve(data)).resolves.toEqual(data);
    });
    test('Promise - Promise.reject', () => {
        let data = { error: 'Error', code: 200 };
        return expect(Promise.reject(data)).rejects.toEqual(data);
    });
});

Async / Await

Para finalizar los test asíncronos usaremos las funciones async / await. Debemos recordar añadir a la función anónima del test la palabra reservada async.

index.test.js

import { sumar, restar, multiplicar, dividir } from '../index';
import { isFalse, isNull, isTrue, isUndefined } from '../index';
import { arrDias, arrProvincias, objExpReg } from '../index.js';
import { callback, ajaxGet } from '../index';

describe('Operaciones matemáticas', () => {
    test('Realizamos la suma', () => {
        expect(sumar(1,1)).toBe(2);
    });
    test('Realizamos la resta', () => {
        expect(restar(1,1)).toBe(0);
    });
    test('Realizamos la multiplicacion', () => {
        expect(multiplicar(1,1)).toBe(1);
    });
    test('Realizamos la division', () => {
        expect(dividir(1,1)).toBe(1);
    });
});
describe('Common matchers', () => {
    const datos = {
        nombre: 'Persona 1',
        edad: 10
    }
    const datos2 = {
        nombre: 'Persona 1',
        edad: 10
    }
    test('Comprobamos que los objectos son iguales', () => {
        expect(datos).toEqual(datos2);
    });
});
describe('Numeric matchers', () => {
    test('Resultado menor que...', () => {
        expect(restar(5,3)).toBeLessThan(3);
    });
    test('Resultado menor o igual que...', () => {
        expect(restar(5,3)).toBeLessThanOrEqual(2);
    });
    test('Resultado mayor o igual que...', () => {
        expect(multiplicar(2,5)).toBeGreaterThanOrEqual(10);
    });
    test('Resultado mayor que...', () => {
        expect(sumar(5,5)).toBeGreaterThan(9);
    });
});
describe('Matchers Boolean, Undefined o Null', () => {
    test('Resultado true', () => {
        expect(isTrue()).toBeTruthy();
    });
    test('Resultado false', () => {
        expect(isFalse()).toBeFalsy();
    });
    test('Resultado undefined', () => {
        expect(isUndefined()).toBeUndefined();
    });
    test('Resultado null', () => {
        expect(isNull()).toBeNull();
    });
});
describe('Matchers Arrays', () => { 
    test('Madrid existe en el array', () => {
        expect(arrProvincias()).toContain('Madrid');
    });
    test('Guadalajara no existe en el array', () => {
        expect(arrProvincias()).not.toContain('Guadalajara');
    });
    test('El array semana tiene 9 elementos', () => {
        expect(arrProvincias()).toHaveLength(9);
    });
    test('Existe Lunes en el array semana', () => {
        expect(arrDias()).toContain('Lunes');
    });
});
describe('Matchers Strings', () => {
    const exp = objExpReg();
    test('Comprobamos si la respuesta es correcta', () => {
        expect(exp.responseOK).toMatch(/OK/);
    });
    test('Comprobamos si la respuesta es incorrecta', () => {
        expect(exp.responseFAIL).toMatch(/FAIL/);
    });
    test('Comprobamos si la respuesta tiene una longitud', () => {
        expect(exp.responseFAIL).toHaveLength(13);
    });
    test('Comprobamos dirección de email', () => {
        expect(exp.email).toMatch(/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.([a-zA-Z]{2,4})+$/);
    })
    test('Comprobamos número de teléfono', () => {
        expect(exp.telefono).toMatch(/^[9|6|7][0-9]{8}$/);
    });
});
afterEach(() => console.log('Despues de cada prueba'));
afterAll(() => console.log("Despues de todas las pruebas"));
beforeEach(() => console.log('Antes de cada prueba'));
beforeAll(() => console.log('Antes de todas las pruebas'));
describe('Asíncrono - Callback', () => {
    test('Callback', done => {
        let callbackInterno = datos => {
            expect(datos).toBe('Hola mundo callback');
            done();
        };
        callback(callbackInterno);
    })
});
describe('Asíncrono - Promise(resolve, reject)', () => {
    test('Promise - Promise(resolve, reject)', done => {
        let url = "http://localhost:3000/posts";
        ajaxGet(url).then(datos => {
            const data = [{ "id": 1, "title": "json-server", "author": "typicode" }];
            expect(datos.length).toBeGreaterThanOrEqual(1);
            expect(datos[0].id).toBeGreaterThanOrEqual(1);
            expect(datos).toEqual(data);
            done();
        });
    });
    test('Promise - .resolves - OK', () => {
        let url = "http://localhost:3000/profile";
        return expect(ajaxGet(url)).resolves.toEqual({"name": "typicode"});
    });
    test('Promise - .rejects - FAIL', () => {
        let url = "http://localhost:3000/no_existe_endpoint";
        return expect(ajaxGet(url)).rejects.toEqual('Not Found');
    });
    test('Promise - Promise.resolve - OK', () => {
        let data = { nombre: 'Test', estado: true };
        return expect(Promise.resolve(data)).resolves.toEqual(data);
    });
    test('Promise - Promise.reject - FAIL', () => {
        let data = { error: 'Error', code: 200 };
        return expect(Promise.reject(data)).rejects.toEqual(data);
    });
});
describe('Asíncrono usando async / await', () => {
    test('Probando async / await - OK', async () => {
        const postAPI = 'http://localhost:3000/posts';
        const commnetsAPI = 'http://localhost:3000/comments';

        const post = await ajaxGet(postAPI);
        const comments = await ajaxGet(commnetsAPI);
        
        expect(post.length).toBeGreaterThanOrEqual(1);
        expect(post[0].id).toBe(1);
        expect(comments.length).toBeGreaterThanOrEqual(1);
        expect(comments[0].body).toBe("some comment");
    });
    test('Probando async / await - FAIL', async () => {
        const failAPI = 'http://localhost:3000/fail';
        await expect(ajaxGet(failAPI)).rejects.toEqual('Not Found');
    });
    test('async / await - rejects & resolves', async () => {
        await expect(Promise.resolve({ response: 'Correcto' })).resolves.toEqual({ response: 'Correcto' });
        await expect(Promise.reject({ errorCode: 500, errorText: 'Not ready' })).rejects.toEqual({ errorCode: 500, errorText: 'Not ready' });
    });
});

Si nos fijamos en las funciones async / await no es necesario usar al final el done, ya que al ser una función declarada como asíncrona espera a que se resuelva y una vez resuelta obtiene los datos.

Snapshot testing

Los snapshots nos garantizan que no vaya a ocurrir algún cambio inesperado en nuestra UI.

Comprobamos lo datos que tenemos con lo que estamos trayendo y que no deben de cambiar, ya que esto lo usamos para casos en donde algún dato en particular muy rara vez cambiara.

En caso de que queramos aceptar el cambio añadiremos el parámetro -u.

Una vez ejecutado el test con snapshot, este nos creara una carpeta con el nombre __snapshots__.

Esta fichero es una captura de los datos que le pasamos en el fichero .json (datos.db.json).

La primera vez que ejecutamos el test crea esa captura que se usara para validar.

snapshot.test.js

import { snapShotObject, href } from '../snapshot';
import geoObject from '../datos.db.json';
import enlace from '../enlace.json';

describe('Snapshot', () => {
    test('Prueba de snapshot', () => {
        expect(snapShotObject(geoObject)).toMatchSnapshot();
    });
    test('Simular UI', () => {
        expect(href(enlace)).toMatchSnapshot();
    });
});

snapshot.js

export const snapShotObject = geo => ({
    "id": geo.id,
    "latitude": geo.latitude,
    "longitude": geo.longitude,
    "active": geo.active
});
export const href = link => ({
    "link": link.link
});

enlaces.json

{
    "link": "<a href='https://www.google.es' class='button'>Enlace</a>"
}

datos.db.json

{
    "id": 1,
    "latitude": "3.5645543",
    "longitude": "40.232332",
    "active": true
}

Puede ocurrir que en algún momento necesitemos añadir una excepción en nuestra snapshot, por ejemplo un dato como obtener la fecha en ese momento.

Nuestro snapshot tendría guardada nuestra captura de la primera vez, pero al realizar otro test posiblemente la fecha sea diferente.

log.test.js

describe('Snapshot', () => {
    test('Log de actividad', () => {
        const log = {
            id: 1,
            date: new Date(),
            active: Math.floor(Math.random() * 100 ) + 1 < 50 ? true : false
        };
        expect(log).toMatchSnapshot();
    }); 
});

snapshot.test.js.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Snapshot Log de actividad 1`] = `
Object {
  "active": false,
  "date": 2018-11-01T17:05:21.758Z,
  "id": 1,
}
`;

Consola primera vez

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 2 passed, 2 total
Tests:       34 passed, 34 total
Snapshots:   1 written, 2 passed, 3 total
Time:        4.637s
Ran all test suites.

Consola resto de veces

FAIL  test/snapshot.test.js
  ● Snapshot › Log de actividad

    expect(value).toMatchSnapshot()

    Received value does not match stored snapshot "Snapshot Log de actividad 1".

    - Snapshot
    + Received

      Object {
        "active": false,
    -   "date": 2018-11-01T17:05:21.758Z,
    +   "date": 2018-11-01T17:13:49.405Z,
        "id": 1,
      }

      16 |             active: Math.floor(Math.random() * 100 ) + 1 < 50 ? true : false
      17 |         };
    > 18 |         expect(log).toMatchSnapshot();
         |                     ^
      19 |     });
      20 | });

      at Object.toMatchSnapshot (test/snapshot.test.js:18:21)

Como hemos comprobado el snapshot original es diferente al actual y por eso el test falla.

Si queremos crear una excepción, añadiremos las excepciones dentro del método .toMatchSnapshot().

Es importante que cuando ejecutemos el test con las excepciones usemos el flag -u para que use los nuevo cambios si ya existiese una captura previa.

snapshot.test.js

test('Log de actividad', () => {
       const log = {
           id: 1,
           date: new Date(),
           active: Math.floor(Math.random() * 100 ) + 1 < 50 ? true : false
       };
       //expect(log).toMatchSnapshot();
       expect(log).toMatchSnapshot({
           date: expect.any(Date),
           active: expect.any(Boolean)
       });
   });

Integrando en un framework

Para realizar la integración de jest en un framework voy a usar Vue.

Crearemos nuestro proyecto desde cero usando el CLI de Vue con el siguiente comnado: vue create nombre-proyecto.

Con el comando anterior se ha creado un esqueleto básico para un proyecto de Vue.

Si nos fijamos en la estructura podemos ver que dentro del proyecto ya existe una carpeta llamada test.

Dentro están los test unitarios y su configuración para usar jest.

Podemos ver el componente (HelloWorld.vue) y el test (example.spec.js).

De todos los ficheros que podemos encontrar dentro del proyecto deberemos prestar atención a los siguientes:

  • test/unit/.eslintrc.js: Configuración lint para los test.
  • test/unit/example.spec.js: Fichero con los test.
  • ./jest.config.js: Configuració de jest en el proyecto.

example.spec.js (fichero de los test)

import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'Prueba de JEST en VUE';
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    });
    expect(wrapper.text()).toMatch(msg);
  });
  
  // 'it' es un alias de 'test'
  // Creamos un Snapshot para probar las capacidades de testing sobre componentes UI
  test('Snapshot VUE', () => {
    const Componente = Vue.extend(HelloWorld);
    const vm = new Componente().$mount();
    expect(vm.$el).toMatchSnapshot();
  });
})

Resultado test en la consola

vue-cli-service test:unit

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ renders props.msg when passed (15ms)
    √ Snapshot VUE (8ms)

 › 1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   1 written, 1 total
Time:        1.641s, estimated 2s
Ran all test suites.

Aunque JEST es mucho mas extenso y tiene más opciones, con este pequeño curso he querido demostrar lo simple que es añadir test a nuestros proyectos.