Dominando el Arte del Testing en JavaScript: Explorando Test Unitarios con Jest
¿Qué es el testing?
Es el conjunto de técnicas utilizadas para verificar que el sistema desarrollado cumple con los requerimientos establecidos. Dentro del mundo de los tests tenemos distintos tipos:
- Tests funcionales: tests unitarios, tests de integración, tests de sistema o end-to-end y test de regresión.
- Tests no funcionales: tests de carga, tests de velocidad, tests de usabilidad y tests de seguridad
En este artículo nos vamos a centrar en los tests unitarios.
¿Qué son los tests unitarios?
Conjunto de pruebas que verifican que pequeñas unidades de software (como una función) cumplan con un comportamiento esperado. Por ejemplo, si tenemos una función que suma dos números, un test unitario podría ser comprobar que el resultado de esa suma sea el correcto.
Características de los tests unitarios
Debemos centrarnos en realizar tests de calidad, que validen los elementos realmente útiles.
Por lo tanto, al realizar estas pruebas tenemos que intentar que cumplan con una serie de características:
- Rápidos → deben ejecutarse lo más rápido posible y poder lanzar las pruebas en repetidas ocasiones sin perder tiempo.
- Aislados → un test no tiene que depender de otro, es decir, tienen que ser totalmente independientes unos de otros.
- Atómicos → hay que intentar que cada test sea lo más pequeño posible y pruebe una sola unidad de código.
- Mantenibles → debemos hacer los tests lo más simple posible para que sean fáciles de mantener, modificar o extender en un futuro.
- Deterministas → mismos resultados a partir de las mismas condiciones de entrada.
- Legibles → cuando leamos un test tenemos que entender fácilmente su propósito, por ello la elección de un buen nombre es primordial.
Estructura de un test unitario
Deben tener una composición muy simple basada en las tres AAA:
- Preparación (Arrange): lo primero es preparar el contexto para realizar la prueba.
- Actuación (Act): ejecución de la acción o unidad de código que queremos probar.
- Aserción (Assert): comprobación de sí el resultado es el esperado.
function sendGreetings(name) {
return `Hello ${name}!`
}
test('Return greetings with name', () => {
//Arrange
const name = "Samuel"
//Act
const result = sendGreetings(name)
//Assert
expect(result).toBe("Hello Samuel")
})
Tests unitarios con Jest
Jest es un framework de testing que nos permite realizar test de una manera rápida y sencilla, y además podemos extraer información de los mismos.
Hay muchos otros frameworks y librerías de testing para JavaScript, principalmente destacan Mocha, Jasmine y Jest. Este último es el framework en el que profundizaremos en este artículo.
Características
- Fácil instalación.
- Retroalimentación inmediata con modo ‘watch’.
- Configuración simple.
- Rápida ejecución con entorno aislado.
- Cobertura de los tests integrada.
- Funciona con TypeScript además de ES6
Instalación y configuración
Podemos utilizar tanto npm como yarn para instalar Jest en nuestro proyecto. En este caso vamos a utilizar npm.
Primero vamos a crear una nueva carpeta para nuestro proyecto, después lo inicializamos y por último instalamos jest:
mkdir testing_with_samuelsan
cd testing_with_samuelsan
npm init
npm install --save-dev jest
Finalmente, en el fichero package.json añadimos lo siguiente en el apartado scripts para poder ejecutar los tests:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll"
}
}
El primer comando “npm test” ejecutará todos los test y se cerrará, mientras que el segundo comando “npm test:watch” ejecutará los test, pero se mantendrá en modo escucha y cada vez que hagamos un cambio en nuestro código volverá a ejecutar los test de forma automática.
Ahora vamos a crear la siguiente estructura de carpetas:
|-src
|---index.js
|-test
|---index.test.js
Dentro de src añadiremos el código de nuestra aplicación y dentro de test añadiremos nuestras pruebas unitarias.
Vamos a realizar nuestra primera prueba unitaria, vamos a crear una función que sume 2 números y generaremos un test que compruebe que se suman correctamente:
//Fichero src/index.js
function sum(a, b) {
return a + b
}
module.exports = {
sum
}
//Fichero test/index.test.js
const { sum } = require('../src/index')
test('Check sum two numbers', () => {
//Arrange
const a = 1
const b = 2
//Act
const result = sum(a, b)
//Assert
expect(result).toBe(3)
})
Cómo vemos en este ejemplo usando el método toBe podemos verificar el valor de la variable que tengamos dentro de expect(). Y este sería el resultado al lanzar el test (npm test):
Al igual que tenemos el método toBe para verificar que 2 valores coinciden, si le agregamos antes la propiedad .not.toBe podemos revisar que 2 valores son distintos. Para ello vamos a agregar el siguiente test:
//Fichero test/index.test.js
test('Check sum two numbers and result is different', () => {
//Arrange
const a = 1
const b = 2
//Act
const result = sum(a, b)
//Assert
expect(result).not.toBe(4)
})
Aserciones
En Jest tenemos múltiples tipos de aserciones, por lo que vamos a seguir añadiendo tests para probar cada una de ellas.
Tenemos aserciones para comprobar la veracidad de ciertos valores, pudiendo distinguir entre undefined, null y false. A continuación os muestro los que a mi juicio me parecen más interesantes:
- toBeNull: comprueba si un valor es ‘nulo’.
- toBeUndefined: revisa si un valor es ‘undefined’, el contrario de este método sería toBeDefined.
- toBeTruthy — toBeFalsy: comprueba si es verdadero o falso.
- toBeGreaterThan: verifica si el valor es mayor que.
- toMatch: puedes probar cadenas de texto contra una expresión regular.
- toContain: verifica si un Array contiene un determinado elemento.
- toThrow: con este método puedes revisar si una función lanza un error en concreto.
Para poder ver todo esto en un ejemplo de código vamos a crear un nuevo fichero dentro de la carpeta src llamado assertions.js:
//Fichero src/assertions.js
class Person {
constructor(name) {
this.name = name
this.hasJob = false
this.lastName = undefined
this.age = null
this.hobbies = []
}
getLastName() {
return this.lastName
}
getAge() {
return this.age
}
setAge(age) {
if (age < 0) throw 'Age should greater than 0'
this.age = age
}
setLastName(lastName) {
this.lastName = lastName
}
setHasJob(hasJob) {
this.hasJob = hasJob
}
addHobby(hooby) {
this.hobbies.push(hooby)
}
}
module.exports = Person
Como podemos ver hemos creado un fichero donde vamos a tener la clase “Person” simulando a una persona. Con base en esta clase generaremos nuestros tests para probar los distintos tipos de aserciones:
//Fichero test/assertions.test.js
const Person = require('../src/assertions')
test('Create new person and check init params', () => {
//Arrange
const person = new Person('Samuel')
//Act
const age = person.getAge()
const lastName = person.getLastName()
//Assert
expect(age).toBeNull() // Comprobamos si es nulo
expect(lastName).toBeUndefined() // Comprobamos si es undefined
expect(person.hasJob).toBeFalsy() // Comprobamos si es falso
})
test('Create new person, set properties and check new data', () => {
//Arrange
const person = new Person('Samuel')
//Act
person.setLastName('Sánchez')
person.setAge(28)
person.setHasJob(true)
person.addHobby('coding')
person.addHobby('football')
const age = person.getAge()
const lastName = person.getLastName()
//Assert
expect(person.hasJob).toBeTruthy() // Comprobamos si es verdadero
expect(age).toBeGreaterThan(25) // Comprobamos si es mayor de 25
expect(lastName).toMatch(/^[A-Z]/) // Comprobamos con una expresión regular si empieza con mayúscula
expect(person.hobbies).toContain('coding') // Comprobamos si tiene 'coding' como hobby
})
test('Create new person but return error because age is negative', () => {
const person = new Person('Samuel')
expect(() => person.setAge(-1)).toThrow()
})
Probar código asíncrono
En JavaScript es muy común tener código asíncrono, por lo que aprender a hacer testing de este código es primordial. Para poder hacer estas pruebas, Jest necesita saber cuándo ha acabado la ejecución de un bloque de código asíncrono y poder seguir con los siguientes tests.
Para ello, Jest tiene varias maneras de resolver esto: Promesas, Async/await y callbacks.
Vamos a crear un nuevo fichero llamado codeAsync.js donde generaremos una función que realiza una llamada a una API y devuelve los resultados de esta llamada:
//Fichero src/codeAsync.js
async function getPokemons() {
const pokemons = await fetch("https://pokeapi.co/api/v2/pokemon")
return pokemons.json()
}
module.exports = {
getPokemons
}
Aquí tenemos una sencilla función que nos devuelve el resultado de una llamada a una api que nos devuelve un listado de pokemons, y posteriormente crearemos el fichero para poder hacer pruebas a este método asíncrono:
//Fichero src/codeAsync.test.js
const { getPokemons } = require('../src/codeAsync')
test('Check pokemons result with async await', async () => {
const pokemons = await getPokemons()
expect(pokemons.count).toBeGreaterThan(0)
})
test('Check pokemons result with promises', () => {
getPokemons()
.then(pokemons => {
expect(pokemons.count).toBeGreaterThan(0)
})
})
En el primer test unitario usamos las palabras clave async/await para esperar a recibir los datos de la api, y en el segundo test usamos la funcionalidad de las promesas para este mismo fin.
Ciclo de vida de un test
Generalmente, cuando realizamos pruebas de nuestro código podemos necesitar realizar operaciones antes o al final de cada test, para ello tenemos estos métodos que nos pueden ayudar:
- beforeEach: se ejecuta antes de cada test.
- afterEach: se ejecuta después de cada test.
- beforeAll: se ejecuta antes de TODOS los test.
- afterAll: se ejecuta después de TODOS los test.
A continuación os voy a mostrar un ejemplo de los métodos con algunos test que realmente no van a probar nada, pero donde veremos con logs el orden de ejecución de estos métodos. Por lo que vamos a crear dentro de la carpeta test un nuevo fichero llamado lifeCycle.test.js :
//Fichero src/lifeCycle.test.js
beforeEach(() => {
console.log("-- Before each --")
})
afterEach(() => {
console.log("-- After each --")
})
beforeAll(() => {
console.log("-- Before all --")
})
afterAll(() => {
console.log("-- After all --")
})
test('test 1', () => {
expect(1+1).toBe(2)
})
test('test 2', () => {
expect(1+1).toBe(2)
})
Cobertura
Por último, Jest nos ofrece la posibilidad de obtener métricas que nos informan de la cantidad de código que ha sido testado por nuestras pruebas, comúnmente es conocido como “coverage”. Para poder realizarlo hay que añadir en la parte scripts de nuestro fichero package.json el comando “jest — coverage”:
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll",
"test:coverage": "jest --coverage"
},
Por lo tanto, al ejecutar este comando “npm run test:coverage” lanzará todos los tests y aparte nos generará las métricas de cobertura de código creando dentro de nuestro proyecto una carpeta llamada “coverage” y dentro de esta carpeta tendremos el fichero “coverage/lcov-report/index.html” que podremos abrir en un navegador. Aparte podremos ver los resultados generales en la terminal donde hayamos ejecutado el comando.
A continuación os dejo el enlace de Github donde podréis ver el repositorio dónde hemos ido realizando este pequeño tutorial.
En resumen, según mi experiencia, contar con una sólida base de pruebas para nuestro código resulta en una notable reducción de errores críticos al poner en funcionamiento nuestra aplicación en producción.
He obtenido gran parte de la información del libro Clean Javascript de Miguel A. Gómez y de la propia documentación del framework de testing Jest.