Da potencia y flexibilidad a tus tests con Jest

Da potencia y flexibilidad a tus tests con Jest
Sin comentarios Facebook Twitter Flipboard E-mail

El testing es uno de los conceptos más core de eXtremme Programming (XP). Ya lo decía el gran Kent Beck:

Any program feature without an automated test simply doesn’t exist

Curiosamente, JavaScript ha sido históricamente uno de los lenguajes con más frameworks de test y menos cultura de testing en su comunidad. Los frameworks han ido apareciendo y desapareciendo a la velocidad del rayo y, por fin hoy, podemos decir que tenemos un magnífico ecosistema para realizar pruebas automáticas que ha venido para quedarse.

En este post vamos a hablar sobre Jest, con él que podemos construir tests unitarios trabajando con matchers personalizados, crear mocks o comprobar snapshots de componentes visuales como algo sencillo y accesible.

Instalación y puesta en marcha

Para completar un ciclo de feedback rápido y con la máxima información en cada momento, debemos de elegir un framework de testing flexible, rápido y con un output sencillo y comprensible. Este es el caso de Jest que, basado en Jasmine, destaca por sus funcionalidades potentes e innovadoras.

Jest ha sido desarrollado por el equipo de Facebook y, aunque nace en el contexto de React, es un framework de testing generalista que podemos utilizar en cualquier situación. Una vez que empecemos con Jest, ya no querréis cambiar :)

Comenzado por el principio, es importante conocer la página principal de Jest , la cual es una de las mejoras guías que encontraréis. En ella podremos encontrar todo tipo de ejemplos y documentación que nos ayudará mucho a profundizar en el framework. En cualquier caso, lo primero es lo primero y para comenzar debemos instalarlo.

Como sucede con cualquier otro paquete JavaScript, podemos añadirlo mediante NPM o Yarn a nuestro proyecto:

npm install --save-dev jest
yarn add --dev jest

Si vamos a utilizar Jest con ES6+, entonces necesitamos algunas dependencias extra (partimos de la suposición de que vamos a usar Babel 6 para este ejemplo):

npm install --save-dev jest babel-jest babel-core regenerator-runtime babel-preset-env
yarn add --dev jest babel-jest babel-core regenerator-runtime babel-preset-env

En el caso de que queramos trabajador con React y procesar JSX, entonces añadiremos también el preset correspondiente (recordad la nueva gestión de presets que babel incorpora en sus últimas versiones):

npm install --save-dev jest babel-jest babel-core regenerator-runtime babel-preset-env babel-preset-react
yarn add --dev jest babel-jest babel-core regenerator-runtime babel-preset-env babel-preset-react

Por último, definiremos en el .babelrc los presets necesarios:

{
  "presets": ["env", "react"]
}

NOTA: En este caso, el preset de React se añade únicamente por si queréis a futuro examinar algunas características avanzadas de Jest como el *snapshot testing, pero no sería realmente obligatorio para el propósito de este artículo.*

Con todo el tooling preparado, que no es poco, si queremos ejecutar los tests desde NPM añadiremos una nueva entrada en la sección de scripts del fichero package.json de nuestro proyecto:

{
  "name": "jest-testing",
  "version": "1.0.0",
  "scripts": {
    "test": "jest"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-jest": "^22.4.3",
    "babel-preset-env": "^1.6.1",
    "babel-preset-react": "^6.24.1",
    "jest": "^22.4.3",
    "regenerator-runtime": "^0.11.1"
  }
}

De esta manera, al ejecutar npm test (o npm t si te van los shortcuts), se invocará Jest y se ejecutarán nuestros tests:

Ejecución de Jest sin tests definidos

Por supuesto, la ejecución falla al no encontrar ningún test todavía en el proyecto, pero esto va a cambiar muy pronto :)

Para ir calentando, vamos a crear un ejemplo un poco chorra que nos permita comprobar que todo funciona correctamente a nivel de configuración del proyecto.

Para ello, crearemos un carpeta test donde almacenar nuestras pruebas automáticas y dentro crearemos un fichero sum.test.js con el siguiente contenido:

test('should sum two numbers', () => {
    let result = 3 + 2;        
    expect(result).toBe(5);
});

No hay demasiado que explicar aquí, verdad??? :)

Vamos pues a ejecutar los tests de nuevo y así nos vamos familiarizando con la salida:

Resultado de la ejecución de nuestros tests

Nuestro primer verde!! Ahora ya no podremos parar.

En los siguientes apartados analizaremos como getionar las distintas fases de definición de un test: Arrange, Act y Assert. En el Arrange estableceremos el estado inicial del que partimos (setup/tearDown), luego ejecutaremos en el Act el código que altera ese estado incial y, por último, realizaremos las comprobaciones necesarias en el Assert utilizando los matchers que Jest nos provee o, incluso, definiendo los nuestro propios.

Definición de contextos

Como estructurar nuestros tests es un aspecto fundamental que condiciona su mantenibilidad a medida que crece el proyecto y el número de tests que tenemos. Generalmente, decimos que los tests se agrupan en suites, que no son más que agrupaciones de tests que están relacionados. Personalmente, prefiero que la causa de agrupación de las pruebas sea un contexto común, un arrange compartido en el contexto de la funcionalidad que abordamos. Es en este tipo de situaciones donde realmente veremos cómo interactuan las funcionalidades que se relacionan cohesivamente.

Para la definición de este tipo de contextos, Jest nos da los siguientes niveles de agrupación:

  • Nivel de fichero. Cada suite puede ir en un fichero distinto siempre y cuando sea detectado por Jest. Esto sucede bajo ciertas condiciones como que se llame *.test.js o que esté dentro de un directorio __test__, por ejemplo.
  • Definición de contextos Podemos agrupar tests mediante la construcción describe bajo un concepto compartido. Ejemplo:
describe('User registration', () => {
   test('....', () => {});
});

Los contextos o describe son anidables, aunque normalmente no se recomienda más de dos niveles de anidamiento ya que complican mucho la legibilidad del conjunto.

Gestión del estado inicial del que partimos: Setup y TearDown

Para la ejecución de una prueba automática es necesario establecer antes de nada un escenario específico que reproduca el estado del que queremos partir. Para ello, podemos tener ya código especializado que inicializa ciertas tablas en una base de datos, crea unos ficheros en el sistema o inicializa ciertas estructuras de datos. Sea como sea, es necesario que nuestro "framework" nos proveea de ciertos métodos que se ejecutarán siempre antes y después de cada test. Es el caso de Jest, estos son beforeEach y afterEach:

beforeEach(() => {
  db.init();
});

afterEach(() => {
  db.clear();
});

test('Car should be present in the catalog', () => {
  expect(db.findCar('BMW')).toBeDefined();
});

Es posible que tanto en beforeEach como en afterEach tengamos que realizar llamadas a código asíncrono (conexión a BD, llamada a servicio o similar). Por este motivo, estas funciones son capaces de recibir promesas como resultado de su invocación, de forma que Jest esperará a que la promesa se complete antes de ejecutar el test. El único punto a tener en cuenta es que la promesa se debe devolver siempre como resultado utilizando return:

beforeEach(() => {
  return db.initAsync();
});

Por último, si la inicialización o limpieza que debemos realizar únicamente se produce al principio de lanzar toda la suite y al final, podremos utilizar las expresiones beforeAll y afterAll:

beforeAll(() => {
  return db.initAsync();
});

afterAll(() => {
  return db.clearAsync();
});

test('Car should be present in the catalog', () => {
  expect(db.findCar('BMW')).toBeDefined();
});

test('Motorbike should be present in the catalog', () => {
  expect(db.findMotorbike('BMW')).toBeDefined();
});

Por otra parte, beforeEach y afterEach se pueden utilizar dentro de un contexo definido con describe y, en ese caso, únicamente se aplicarían a los tests que están definidos en ese contexto:

Con el fin de podamos comprender la secuencia concreta de métodos disponibles y como se van ejecutando en Jest, podemos ver el output de la siguiente secuencia de definiciones:

beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'));
  afterAll(() => console.log('2 - afterAll'));
  beforeEach(() => console.log('2 - beforeEach'));
  afterEach(() => console.log('2 - afterEach'));
  test('', () => console.log('2 - test'));
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

Matchers

Los matchers son las funciones utilizadas por el framework de test para comprobar si el valor esperado por la prueba automática coincide realmente con el obtenido. Evidentemente, si el valor no coincide el test fallará (FAIL) y nos mostrará, marcando la salida en rojo, cual ha sido la discrepancia de valores. Por otra parte, si valor esperado coincide con el obtenido, tendremos ese adictivo verde (PASS) que nos llevará a escribir el siguiente test.

Es por esto que, cuando comparamos dos valores al final de un test, es importante conseguir que no se pierda la intención que queremos expresar con esta prueba automática. Recordemos que los tests son la documentación ejecutable de nuestro proyecto y, si esta no es entendible y sencilla, discernir lo que realmente el software será mucho más complicado y, por consiguiente, tendremos que invertir mucho más tiempo en su mantenimiento y seguramente acabaremos cometiendo muchos más errores.

Cuando comparamos dos valores al final de un test, es importante conseguir que no se pierda la intención que queremos expresar con esta prueba automática

Si partimos de la base, nuestro test en su estructura básica de Arrange, Act y Assert deberá ser rápido de leer y sencillo de entender. No hay nada peor que encontrarse con un test en rojo, leerlo y toparse con una definición compleja que no somos capaces de asimilar en pocos segundos.

Para evitar esta situación y conseguir que nuestros tests sean realmente "semánticos", el naming es fundamental. Utilizar buenos nombres de definición de nuestros tests, buenos nombres de variables, aserciones de negocio (ya veremos más adelante que son) y cualquier otra técnica que mejore la expresividad, nos salvará la vida cuando más lo necesitemos.

Pero bueno, todo esto llegará poco a poco y, si realmente interiorizamos estos principios, podremos ir mejorando nuestra base de test ganando así velocidad de cambio en el futuro.

Para ir avanzado, podemos partir de lo básico que nos ofrece Jest por defecto para comparar tipos primitivos como son cadenas, valores numéricos o colecciones, dejando para la siguiente sección la definición de comparaciones o "matchers" personalizados. Comparaciones disponibles:

Igualdad

Ya sabemos que la igualdad en JavaScript es un tema delicado, así que en este comparador lo mejor es aclarar que se aplicará tal y como se define en el estándar para Object.is (si no conocéis esta función, os recomiendo explorar este y otros métodos de Object que muchas veces obviamos a la hora de escribir código):

expect(3 + 2).toBe(5);

Si en lugar de comprobar igualdad queremos verificar lo contrario, podemos seguir utilizando el comparador toBe precedido de not. Personalmente me encanta esta aproximación desde el punto de vista de la semántica extra que añade, ya que creo que se asemeja mucho más al lenguaje natural y facilita su lectura cuando se compara con otras aproximaciones de otros frameworks.

expect(3 + 3).not.toBe(5);

Para finalizar con las comparaciones de igualdad, no podíamos dejarnos fuera de la ecuación los valores que en JavaScript pueden ser truthy o falsy. Es por esto que en Jest tenemos soporte para cualquier comparación del estilo:

expect(null).toBeNull();
expect(null).toBeDefined();
expect(null).not.toBeUndefined();
expect(null).not.toBeTruthy();
expect(null).toBeFalsy();

Objetos y listas

Para el caso de estructuras complejas como son los objetos y las listas, incluidas aquellas que pueden presentar distintos niveles de profundidad, el operador adecuado no es tanto toBe sino toEqual, al permitir una comparación profunda o deep equality que evalua correctamente la estructura completa:

let data = {one: 1};
data['two'] = 2;

expect(data).toEqual({one: 1, two: 2});
expect(data).toEqual({two: 2, one: 1});

En el caso concreto de las listas, algo bastante habitual es el poder comprobar si el resultado de una operación que devuelve algún tipo de colección contiene el valor buscado. Para ello, haremos uso del matcher toContain, que nos permite explorar esta colección sin tener que iterar sobre ella y sin tener que utilizar funciones como indexOf o includes en la colección:

expect(['cat', 'beer', 'dog']).toContain('beer');

Valores numéricos

Ya fuera de los comparadores más básicos de igualdad, en el caso de valores numéricos disponemos de muchas comparaciones que aportan un valor semántico extra a la comprobación que intentamos expresar. Algunos de ellos son:

expect(2+2).toBe(4);
expect(2+2).toEqual(4);
expect(2+2).toBeGreaterThan(3);
expect(2+2).toBeGreaterThanOrEqual(3.5);
expect(2+2).toBeLessThan(5);
expect(2+2).toBeLessThanOrEqual(4.5);

expect(0.1 + 0.2).not.toBe(0.3);
expect(0.1 + 0.2).toBeCloseTo(0.3);

No hay nada más poco expresivo que encontrarse suites de test que únicamente hacen uso de los matchers más básicos, conformándose con su limitado valor expresivo. En este sentido, preferimos expresiones de este tipo:

expect(2+2).toBeGreaterThan(3);

Sobre otras menos específicas como la siguiente, en la que únicamente nos valemos del matcher más básico como es toBe:

expect(2+2 > 3).toBe(true);

Expresiones regulares

Cuando construimos nuestro conjunto de aserciones en los tests que diseñamos, una buena práctica es no acoplarse en exceso a las fixtures utilizadas o a la implementación de los métodos. En este sentido, lo que buscamos es validar más la estructura de la información procesada que los datos en si. Es por ello que es bastante habitual en las aserciones de nuestras tests encontrar expresiones regulares que validen un patrón en los datos, más que un resultado concreto.

Una buena práctica es no acoplarse en exceso a las fixtures utilizadas o a la implementación de los métodos

Para poder expresar este tipo de comparaciones podemos hacer uso del matcher toMatch y pasarle una expresión regular estándar:

expect('Christoph').toMatch(/stop/);

Excepciones

Partir de lo que conocemos como Happy Path en nuestros tests puede ser una buena manera de empezar a razonar sobre la funcionalidad y explorar así cual sería un diseño adecuado para la misma. Es una práctica habitual si estamos haciendo TDD. Siguiendo este proceso y a medida que avanzamos, es posible que surjan situaciones a tener en cuenta y que, por no ser tan habituales o tratarse de situaciones excepcionales, no habíamos tenido en cuenta en un incio. Para este caso, lo más recomendable es anotarlas y abordarlas en próximas iteraciones, de forma que no nos corten el flujo de trabajo que estemos siguiendo en ese momento.

Por consiguiente, si más tarde volvemos a revisitar estas situaciones excepcionales que habíamos anotado, debemos de saber que Jest nos va a poder ayudar de nuevo a evaluarlas, permitiendo recoger excepciones y otros errores "esperados" ante una Arrange concreto.

const computeValue = () => {
    throw new Error('should fail');
};

expect(computeValue).toThrow();
expect(computeValue).toThrow(Error);
expect(computeValue).toThrow('should fail');
expect(computeValue).toThrow(/fail/);

Matchers personalizados

Como hemos podido comprobar en el apartado anterior, Jest cuenta con un conjunto de matchers muy rico y que aportan mucha semántica a las comprobaciones que necesitamos realizar a la hora de escribir tests. En cualquier caso, estos matchers siguen siendo demasiado generalistas en muchos contextos y, si lo queremos es que nuestro código hable de "negocio", necesitamos poder definir nuestros propios "matchers de negocio".

Tal y como comentaba, un "matcher de negocio" es una matcher que usa expresiones de nuestro dominio a la hora de verificar una comprobación. Si trabajamos con una factura, la idea sería tener un matcher que compruebe toBePaid, en lugar de acceder al importe de la misma y comprobar que el importe es superior a 0 con toBeGreaterThan. Nada que ver!!

// Correcto pero poco semántico
expect(invoice.getPaymentDate()).toBeDefined(); 

// Muchísimos más semántico y próximo al lenguaje del dominio!!! ;)
expect(invoice).toBePaid();

Ahora que tenemos un poco más claro el valor de poder definir matchers personalizados y próximos al dominio, vamos a ver como realmente poder utilizarlos en Jest. Para ello, y si queremos que los matchers que definamos estén disponibles en todas mis suites de test, tendremos que declararlos de forma global.

Simplemente editaremos el fichero package.json y añadiremos la propiedad setupTestFrameworkScriptFile dentro de la clave jest apuntando al fichero que contendrá la definición de los matchers (en este caso setup.js):

{
  "name": "basic-sum",
  "version": "1.0.0",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-jest": "^21.2.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1",
    "jest": "^21.2.1",
    "regenerator-runtime": "^0.11.0"
  },
  "jest": {
    "setupTestFrameworkScriptFile": "/test/setup.js"
  }
}

Si ahora creamos el fichero setup.js dentro del directorio test de nuestro proyecto, podremos definir haciendo uso de la función extend los matchers que necesitemos (en este ejemplo la posibilidad de comprobar si un valor numérico concreto está dentro de un rango):

import expect from 'expect';

expect.extend({
  toBeInRange(received, lower, upper) {
    if (received >= lower && received <= upper) {
      return { pass: true };
    }

    return {
      message: () => `expected ${received} to be in range`,
      pass: false,
    };
  }
});

Finalmente, ya puedo hacer uso de los nuevos matchers personalizados que he definido en cualquier de los tests que implemente:

test('should sum', () => {
  expect(100).toBeInRange(90, 120);
});

Watch mode

Una de las funcionalidades que resultan más prácticas es lanzar Jest en watch mode para que así pueda ir ejecutando los tests cada vez que se cambie un fichero. Esto que puede parecer mucho en bases de código con suites muy grandes, es especialmente interesante en Jest ya que ante un cambio buscará los tests a los que afecta este cambio y los relanzará, evitando el procesamiento de toda la suite completa cada vez. De hecho, en el caso en el que se encuentre con un error que detenga la ejecución, lanzará el test fallido el primero cuando se vuelvan a tirar los tests, evitando procesar antes los que ya estaban en verde.

Para ejecutar el modo watch, podemos añadir un nuevo script a nuestro package.json, de forma que lo podamos lanzar de una manera simple ejecutando npm run test:watch:

{
  "name": "jest-testing",
  "version": "1.0.0",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-jest": "^22.4.3",
    "babel-preset-env": "^1.6.1",
    "babel-preset-react": "^6.24.1",
    "jest": "^22.4.3",
    "regenerator-runtime": "^0.11.1"
  }
}

Una vez lanzado, nos encontraremos con una salida como la siguiente:

Captura De Pantalla 2018 06 19 A Las 17 23 25

Como podemos ver en la parte de usage, tenemos también la opción de filtrar qué tests se van a ejecutar con el fin de centrarnos únicamente en un subconjunto de los tests y no estar lidiando con el set completo que puede ser muy numeroso. Maravilloso!! :)

Cobertura

La cobertura de nuestra suite de test es el tanto por cien del código de producción por el que pasa alguna de nuestras pruebas automáticas.

La cobertura es una de esas métricas que las carga el diablo. Si nos sirve para ir detectando partes del código o hot spots en los que hemos incidido menos y que son importantes porque absorben mucho cambio, entonces la cobertura es nuestra amiga. Si la usamos como un indicador de calidad de cara a negocio o que el propio negocio nos exige para cumplir con ciertos requisitos, entonces puede ser peligroso, ya que podemos subirla rápidamente sin realmente estar prestando atención a la efectividad de las pruebas que incorporamos. En general, huid de buscar un valor concreto o de intentar superar un umbral prefijado. Si la tomamos como una métrica orientativa que, apoyada en las historias de usuario que nos entran, nos permite dirigir el cambio y nuestro empeño cuando hacemos testing, entonces nos aportará mucho valor.

La buena noticia es que obtener un análisis de cobertura en Jest es extremadamente sencillo, ya que puede ser obtenida mediante la opción --coverage, de forma que de nuevo podemos ampliar la sección de scripts de nuestro package.json para lanzar Jest con esta opción añadida a la que únicamente querremos recurrir cuando sea necesario:

{
  "name": "jest-testing",
  "version": "1.0.0",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-jest": "^22.4.3",
    "babel-preset-env": "^1.6.1",
    "babel-preset-react": "^6.24.1",
    "jest": "^22.4.3",
    "regenerator-runtime": "^0.11.1"
  }
}

Cuando ejecutamos Jest con cobertura, se mostrará una tabla resumen extra al final de la ejecución de nuestros tests que tendrá más o menos este aspecto:

Captura De Pantalla 2018 06 19 A Las 17 42 47

Conclusiones

Como te puedes imaginar, esto es sólo la punta del iceberg y Jest esconde muchas otras sorpresas interesantes. Entre otras muchas, cabe destacar el mocking automático a distintos niveles, el soporte para snapshot testing y la integración por defecto de JSDOM para hacer tests sobre código que necesite tener disponible el DOM del navegador (React, Vue, Angular, etc) sin necesidad de levantar un navegador.

Muchas posibilidades para iniciarse en una de las prácticas de XP que más valor aporta al diseño y a la estabilidad de nuestro software.

Comentarios cerrados
Inicio