Hacer test de componentes en React con Enzyme

Algo que siempre hemos dejado para última hora, o bien dejamos de hacerlo: los tests. Empezaré diciendo que yo también soy nuevo en los tests y que esto será un resumen de lo que voy aprendiendo en el tema testing 😜

Hace unos días entré en un nuevo proyecto y se quiere dejar testeado muchas cosas, con cierto criterio, así que me he puesto a investigar la librería Enzyme con el objetivo de testear algunos componentes de React. La curva de aprendizaje es relativamente sencilla, así que voy a intentar explicar algunos conceptos básico para ir quitándonos el miedo a los tests.

Índice de contenidos:

  • Instalación de Enzyme en React
    • React 16
    • React 17
  • Snapshots
  • Configuración de Enzyme en React
    • Configuración de Enzyme para React 16
    • Configuración de Enzyme para React 17
  • Componente de ejemplo para los tests:
  • Tests que vamos a realizar sobre el componente
  • Testing
    • Importamos las dependencias
    • ¿Qué es describe?
    • Los tests, ¡por fin!
    • Test 1: debería mostrar correctamente
    • Test 2: debe de mostrar el valor por defecto de 100
    • Test 3: debe incrementar con el botón +1
    • Test 4: debe decrementar con el botón -1
    • Test 5: debe de colocar el valor por defecto con el botón reset
    • Resultado final de los tests
    • beforeEach
  • Conclusiones

Instalación de Enzyme en React

Lo primero que debemos de mirar es la documentación de Enzyme para la instalación, y aquí haremos una matización.

React 16

Si tienes la versión 16 de React, te servirá la documentación actual (este documento lo estoy escribiendo el 8 de febrero del 2021). Si no sabes que versión de React estás usando, ve al package.json y verás en las dependencias algo como:

Versión de React en tu proyecto

Si este es tu caso la instalación sería de la siguiente forma:

npm i --save-dev enzyme enzyme-adapter-react-16

React 17

Si tienes la versión 17 de React, tendraás que hacer un pequeño cambio, ya que oficialmente Enzyme no da soporte a la versión 17 (este documento lo estoy escribiendo el 8 de febrero del 2021).

Nota: Si cuando leas este artículo React ya da soporte para la versión 17 no es necesario que hagas la configuración de este modo

Si este es tu caso la instalación sería de la siguiente forma:

npm i --save-dev enzyme

Y luego necesitaremos el adaptador para la versión 17. No es un adaptador oficial, pero Wojciech Maj nos ha dejado uno, de momento, no oficial.

npm install --save-dev @wojtekmaj/enzyme-adapter-react-17

Snapshots

Sólo queda una cosa más. Para poder hacer "capturas" de nuestros componentes y guardarlos en snapshots para hacer ciertas pruebas, vamos a necesitar un paquete que se llama enzyme-to-json y lo puedes instalar de la siguiente forma:

npm install --save-dev enzyme-to-json

Configuración de Enzyme en React

Esto es una de las mejores cosas, lo fácil que es configurar Enzyme en React. Simplemente abre el archivo src/setupTests.js y lo dejaremos de la siguiente manera (si no tienes este archivo, créalo):

Configuración de Enzyme para React 16

import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { createSerializer } from "enzyme-to-json";

Enzyme.configure({ adapter: new Adapter() });
expect.addSnapshotSerializer(createSerializer({ mode: "deep" }));

Configuración de Enzyme para React 17

Nota: Si cuando leas este artículo React ya da soporte para la versión 17 no es necesario que hagas la configuración de este modo

import Enzyme from "enzyme";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { createSerializer } from "enzyme-to-json";

Enzyme.configure({ adapter: new Adapter() });
expect.addSnapshotSerializer(createSerializer({ mode: "deep" }));

¡Perfecto! 🎉 🚀 Ahora ya lo tenemos todo listo para empezar con nuestros tests.

Componente de ejemplo para los tests:

Bien, para nuestro ejemplo vamos a utilizar el clásico ejemplo de un contador. Básicamente tendrá tres acciones:

  1. Botón para aumentar +1 el contador
  2. Botón para resetear el contador
  3. Botón para restar -1 el contador

Quedando así:

import React, { useState } from "react";

export const CounterApp = ({ value = 10 }) => {
  const [counter, setCounter] = useState(value);

  const handleUp = () => setCounter((counterPref) => counterPref + 1);
  const handleDown = () => setCounter((counterPref) => counterPref - 1);
  const handleReset = () => setCounter(value);

  return (
    <>
      <h1>Counter App</h1>
      <div>
        <h2>{counter}</h2>
        <div>
          <button onClick={handleUp}>+1</button>
          <button onClick={handleReset}>Reset</button>
          <button onClick={handleDown}>-1</button>
        </div>
      </div>
    </>
  );
};

Y lo usamos de la siguiente manera:

<CounterApp value="{100}" />

Y visualmente quedaría algo como:

Versión de React en tu proyecto

Tests que vamos a realizar sobre el componente

Bien, las pruebas que vamos a hacer serán las siguientes:

  1. Se debería mostrar correctamente.
  2. Debe de mostrar el valor por defecto de 100
  3. Debe incrementar con el botón +1
  4. Debe decrementar con el botón -1
  5. Debe de colocar el valor por defecto con el botón reset

Testing

Primero pongo todo el test como va a quedar y lo iré explicando. Lo primero que debemos de crearnos es una carpeta donde iremos poniendo todos los tests, en mi caso, me he creado una carpeta tests (en plural porque habrá más de uno) y dentro he colocado un CounterApp.test.js. Es MUY IMPORTANTE poner en el nombre .test porque sino React no se enterará de que eso es un test como tal. No lo olvides.

import "@testing-library/jest-dom";
import { shallow } from "enzyme";
import { CounterApp } from "../CounterApp";

describe("Probamos el componente <CounterApp />", () => {
  let wrapper = shallow(<CounterApp />);

  beforeEach(() => {
    wrapper = shallow(<CounterApp />);
  });

  test("debería mostrar <CounterApp /> correctamente ", () => {
    expect(wrapper).toMatchSnapshot();
  });

  test("debe de mostrar el valor por defecto de 100", () => {
    const wrapper = shallow(<CounterApp value={100} />);
    const counterText = wrapper.find("h2").text().trim();
    expect(counterText).toBe("100");
  });

  test("debe incrementar con el botón +1", () => {
    wrapper.find("button").at(0).simulate("click");
    const counterText = wrapper.find("h2").text().trim();
    expect(counterText).toBe("11");
  });

  test("debe decrementar con el botón -1", () => {
    wrapper.find("button").at(2).simulate("click");
    const counterText = wrapper.find("h2").text().trim();
    expect(counterText).toBe("9");
  });

  test("debe de colocar el valor por defecto con el botón reset", () => {
    const wrapper = shallow(<CounterApp value={105} />);
    wrapper.find("button").at(0).simulate("click");
    wrapper.find("button").at(1).simulate("click");
    const counterText = wrapper.find("h2").text().trim();
    expect(counterText).toBe("105");
  });
});

Bien, vamos a explicar todo un poco.

Importamos las dependencias

import "@testing-library/jest-dom";
import { shallow } from "enzyme";
import { CounterApp } from "../CounterApp";

Esto no nos va a sorprender, ¿verdad?

  • @testing-library/jest-dom dependencia no es obligatoria importarla, pero si recomendable porque así nos habilitará el IntelliSense para los tests, y esto es algo muy cómodo para no tener que ir recordando todos los nombres de las funciones y demás.
  • enzyme es la librería con el core de enzyme para los tests. Importación obligatoria.
  • CounterApp es nuestro componente con su ruta relativa que vamos a testear.

¿Qué es describe?

´describe´ nos permite agrupar uno o varios tests para que de algún modo quede todo más legible y ordenado. En nuestro caso vamos a hacer un grupo (describe) con varias pruebas dentro de el mismo (test)

describe("Probamos el componente <CounterApp />", () => {
  // Aquí dentro irán los tests para este grupo
});

Los tests, ¡por fin!

let wrapper = shallow(<CounterApp />);

beforeEach(() => {
  wrapper = shallow(<CounterApp />);
});

shallow es una función de Enzyme que se utiliza para probar componentes de forma aislada, ya que no renderiza los subcomponentes. Si deseas renderizar los subcomponentes utiliza render o mount.

Así pues, con en wrapper nos estamos "guardando" el componente para poder usarlo en las siguientes pruebas.

Con el beforeEach lo que le decimos que el componente se reinicie al estado inicial cada vez que inicia una nueva prueba (esto lo explicaré luego).

Nota: Observarás que duplico la línea para definir el wrapper con el shallow (shallow(<CounterApp />)). No es lo más bonito la verdad, pero es la forma para mantener el IntelliSense a lo largo del archivo de tests. Si conoces una forma más limpia estoy abierto a todos los comentarios 🙃

Test 1: debería mostrar correctamente

Ahora entramos en materia buena. Lo primero que te recomiendo es pegarle un vistazo a la documentación sobre expect para que veas todas las cosas que podemos hacer con él.

test("debería mostrar <CounterApp /> correctamente ", () => {
  expect(wrapper).toMatchSnapshot();
});

Con esto le decimos al test que esperamos que el componente se renderice correctamente y nos cree nuestro snapshot. ¿Qué es un snapshot? Pues básicamente una copia del html resultante que genera el componente. Verás que ahora tienes una nueva carpeta en src/tests/__snapshots__ con un archivo src/tests/__snapshots__/CounterApp.test.js.snap que se verá así:

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

exports[`Probamos el componente <CounterApp /> debería mostrar <CounterApp /> correctamente  1`] = `
<Fragment>
  <h1>
    Counter App
  </h1>
  <div>
    <h2>
      10
    </h2>
    <div>
      <button
        onClick={[Function]}
      >
        +1
      </button>
      <button
        onClick={[Function]}
      >
        Reset
      </button>
      <button
        onClick={[Function]}
      >
        -1
      </button>
    </div>
  </div>
</Fragment>
`;

Si todo esto es correcto, perfecto, vamos bien 😌

Test 2: debe de mostrar el valor por defecto de 100

test("debe de mostrar el valor por defecto de 100", () => {
  const wrapper = shallow(<CounterApp value={100} />);
  const counterText = wrapper.find("h2").text().trim();
  expect(counterText).toBe("100");
});

Aquí instanciamos de nuevo el componente y se lo asignamos a una variable wrapper, pero…. ¿porqué no usamos el wrapper que definimos al principio? Mira las diferencias:

const wrapper = shallow(<CounterApp value={100} />);
let wrapper = shallow(<CounterApp />);

En este nuevo caso necesitamos pasarle por props el valos concreto que queremos, en este caso el 100 (por defecto el componente coge el valor 10, recuerda la definición del componente que era export const CounterApp = ({ value = 10 }))

Lo siguiente, el counterText es una variable en la que queremos guardar el texto que contiene la etiqueta h2 de nuestro componente. Si recordamos nuestro componente, tenemos:

<h2>{counter}</h2>

Entonces, con wrapper.find('h2').text().trim() le decimos que busque la etiqueta <h2>, obtenga su texto contenido y le aplica un trim por si acaso tiene espacios en blanco delante o detrás. Esto, como verás, es muy parecido al jQuery 🤨

Finalmente hacemos la comprobación: expect(counterText).toBe('100') que básicamente es "preguntarle" a counterText si es === '100'.

Test 3: debe incrementar con el botón +1

test("debe incrementar con el botón +1", () => {
  wrapper.find("button").at(0).simulate("click");
  const counterText = wrapper.find("h2").text().trim();
  expect(counterText).toBe("11");
});

Lo primero que debemos obtener es el botón +1. Recordemos nuestro componente:

<button onClick="{handleUp}">+1</button>
<button onClick="{handleReset}">Reset</button>
<button onClick="{handleDown}">-1</button>

Cuando hacemos wrapper.find('button') obtenemos todos los botones de nuestro componente y nos los guarda en un array. Así pues, en la posición 0 estará el +1, en la posición 1 estará el reset y en la posición 2 estará el -1. Fácil, ¿no?

Entonces, capturamos el botón +1 y simulamos un click de la siguiente forma: wrapper.find('button').at(0).simulate('click') y buscamos de nuevo el valor que contiene la etiqueta h2 y lo verificamos: expect(counterText).toBe('11')

Test 4: debe decrementar con el botón -1

test("debe decrementar con el botón -1", () => {
  wrapper.find("button").at(2).simulate("click");
  const counterText = wrapper.find("h2").text().trim();
  expect(counterText).toBe("9");
});

Entonces, capturamos el botón -1 y simulamos un click de la siguiente forma: wrapper.find('button').at(2).simulate('click') y buscamos de nuevo el valor que contiene la etiqueta h2 y lo verificamos: expect(counterText).toBe('9'). Fácil.

Test 5: debe de colocar el valor por defecto con el botón reset

test("debe de colocar el valor por defecto con el botón reset", () => {
  const wrapper = shallow(<CounterApp value={105} />);
  wrapper.find("button").at(0).simulate("click");
  wrapper.find("button").at(1).simulate("click");
  const counterText = wrapper.find("h2").text().trim();
  expect(counterText).toBe("105");
});

Este test nos servirá para comprobar que el valor vuelve a ser el que le hemos pasado una vez le hemos sumado +1 y le pulsamos el botón reset. Del código de este test ya nos debería de sonar todo:

Primero, definimos un nuevo wrapper porque queremos pasarle un valor por defecto, para nuestro ejemplo será el 105. Luego pulsamos el botón de la posición 0 que es el de sumar +1 (ahora el valor en el componente será de 106).

Luego hacemos otro click, el del botón de la posición 1 que es el de Reset para que vuelva al valor pasado por props (105). Y obtenemos el valor de nuevo de la etiqueta h2. ¿Resultado? 105 😉

Resultado final de los tests

Si todo ha ido bien deberías de ver todos los checks verdes.
Checks verdes, test en Enzyme pasados correctamente

¡Es hora de celebrarlo! 🎉

beforeEach

Prueba a comentar la línea del beforeEach:

beforeEach(() => {
  wrapper = shallow(<CounterApp />);
});

Y vuelve a correr los tests.

Checks rojos, test en Enzyme con errores

¿Que ha pasado? ¡Pero si no he tocado nada del código! La explicación es sencilla, y verás que tiene su lógica.

Todos los tests se ejecutan de forma secuencial. Como verás nos ha fallado el test al comprobar el valor cuando restamos -1. El test esperaba recibir el valor de 9, pero en cambio recibe un valor de 10. ¡¿WTF?! Recuerda, el valor por defecto es de 10, en la prueba debe incrementar con el botón +1 le hemos sumado +1 (11), y en la siguiente debe decrementar con el botón -1 le hemos restado -1 a ese valor 11 que teníamos de la prueba anterior, por tanto tenemos el 10. De ahí el error.

Entonces, con beforeEach, lo que hacemos es reiniciar el componente en cada test que queremos pasar y así nos aseguramos siempre del estado que queremos tener y esperamos para cada uno de ellos. 👏🏻

Conclusiones

A todos nos gusta picar código desde un primer momento y nos olvidamos de los tests, yo incluido, bien por falta de tiempo del proyecto, o bien por pereza.

Pero para hacer tests no debemos volvernos tampoco locos. Testea con cabeza y no quieras hacer tests de cosas que no te sean necesarias. Verás que la curva de aprendizaje no es alta, y poco a poco le cogerás el punto y ganarás en salud, sobre todo en eso 😂

¡Haz tests! 🙏🏻

Repo: https://github.com/alextomas80/testing-components-enzyme

Y esto es todo. Espero que te pueda servir 🙃