Pruebas unitarias efectivas en el frontend 🧪

Aunque este tema ha sido comentado múltiples veces —sobretodo los últimos años— , después de tener algunos debates con compañeros, he decidido escribir este post.

El post no tiene por finalidad sentar cátedra o enseñar a hacer pruebas desde 0, sino exponer mi visión sobre cómo creo que se deberían enfocar las pruebas unitarias del frontend (la cual está muy en línea con la visión de Kent C. Dodds).

No quiero meterme en el tooling, ya que eso podría dar para otro post, pero usaré Jest con Testing Library para los ejemplos.

Sí, la Pirámide del Testing otra vez 🙄

Muchos conocemos esta pirámide o incluso la hemos usado de referencia para hacer nuestras pruebas.

La pirámide de pruebas

La pirámide sugiere que la base de nuestras pruebas debe ser unitaria, complementada por pruebas de integración o servicio, y coronada por las pruebas e2e (end-to-end) o UI.

Este enfoque se justifica por su eficiencia en términos de costes de desarrollo y ejecución. Y tiene sentido, ya que a más subimos en la pirámide, más recursos necesitamos.

Pero.

Aunque a priori suena bien, hay que tener en cuenta que esta pirámide se presentó entorno al 2010. Desde aquél entonces, el desarrollo frontend ha evolucionado muchísimo. Ha pasado de ser un punto de conexión para interactuar con el backend, a ser un gigante por sí solo.

Timeline aproximado de los avances en el testing de frontend
Timeline aproximado de los avances en el testing de frontend
Timeline aproximado de los avances en el testing de frontend

Además, ahora contamos con una conexión a internet mucho más rápida, ofreciendo una experiencia de usuario mejorada, páginas web con una interacción más intensa con los usuarios y un mayor dinamismo en general.

No estoy diciendo que la pirámide esté mal, pero hay que tener cuidado cuando la interpretamos, ya que basar nuestra estrategia de pruebas en la versión de 2009-2010 puede ser potencialmente confuso.

La pirámide es una guía que se ha ido revisando a lo largo de los años, no una regla estricta.

¿Qué es una prueba unitaria en el frontend? 👀

…¿Es un componente de UI? ¿un método de un servicio? ¿una página? ¿es el should render correctly que hemos visto tantas veces? 🤨

Esta pregunta no puede resolverse sin preguntarnos primero, ¿Por qué hacemos pruebas?

Hacemos pruebas porque queremos asegurarnos de que nuestro producto funciona como esperamos, que no rompemos la funcionalidad existente ni bloqueamos al usuario.

Es complicado buscar una definición de prueba unitaria que encaje en todos los tipos de proyecto. No será igual la prueba unitaria en una librería de componentes, donde nuestros usuarios son otros desarrolladores, que un marketplace, donde el usuario final no tiene conocimiento técnico.

Por ejemplo, The Testing Trophy es una estrategia de testing popularizada por Kent C. Dodds, donde se defiende que la base de nuestras pruebas deberían ser pruebas de integración.

En su contexto, clasifica las pruebas así:

  • “Las pruebas unitarias son aquellas que prueban unidades que, o bien no tienen dependencias, o bien las tienen mockeadas para la prueba.”
  • “Las pruebas de integración son las que comprueban la integración de varias unidades entre sí.”

Por otro lado, Martin Fowler hace uso de los conceptos “solitary test” para las pruebas unitarias aisladas, y “sociable test” para las pruebas unitarias que no simulan sus dependencias.

Si me preguntan cuál es mi definición de prueba unitaria, diría que se acerca a la utilizada en TDD (Test Driven Development): una prueba abarca un escenario, requerimiento funcional o criterio de aceptación. Como queremos que sean rápidas de ejecutar y lo menos flaky (inestables) posible, simulamos el backend.

La definición de Kent C. Dodds sugiere que si queremos asegurar que el usuario es capaz de usar nuestra aplicación, una prueba de un componente aislado, con sus dependencias (componentes hijos, servicios…) simuladas, tiene algunos trade-offs.

… ¿Por ejemplo?

Caso práctico de prueba unitaria

Imagina que nos piden desarrollar un formulario para iniciar sesión en la página /login.

Hacemos los componentes Button y Form:

// button.jsx
const Button = ({children, onClick}) => {
  return <button onClick={onClick}>{children}</button>
};

// form.jsx
const Form = () => {
  const handleSubmit = () => {
	  //	...
  }
  return (
    <form>
      <Button onClick={handleSubmit}>Enviar</Button>
    </form>
  )
}

Hay muchas formas de probar este caso. Imaginemos que optamos por crear un fichero de pruebas por cada elemento que necesitamos.

// button.test.jsx
it('should call onClick when clicked', () => {
  const onClick = jest.fn();
  render(<Button onClick={onClick}>Enviar</Button>);

  fireEvent.click(screen.getByText('Enviar'));

  expect(onClick).toHaveBeenCalled();
  expect(onClick).toHaveBeenCalledWith(...);
});

Posteriormente, realizaríamos más pruebas unitarias para Form.jsx, para los servicios, etc. Igualmente con sus dependencias simuladas.

🔎 Desventajas:

1. Las pruebas no reflejan cómo se usa nuestra aplicación

Este enfoque tiende a acotar demasiado las pruebas, lo que nos lleva a enfocarnos en detalles de implementación: aspectos que no son relevantes para el usuario.

Al usuario final no le interesa saber que al pulsar un botón se llama a un método, o que un componente pasa ciertas props a otro. Al usuario le interesa que dado un input, se genera un output.

Nos enfocamos en hacer aserciones de requisitos técnicos, y dedicamos menos atención a probar la funcionalidad en escenarios completos, donde se verifica que todos los elementos funcionen en armonía.

2. Fricción al refactorizar

Hay otro gran riesgo: si queremos refactorizar nuestro componente, podría generar fricción en las pruebas.

Por ejemplo, si cambiamos el nombre de Button a Botoncito, o modificamos el nombre de la prop onClick a onClickButton, la prueba en button.test.js fallaría al no reconocer estos nuevos cambios, a pesar de que la lógica de negocio se mantenga intacta.

Vale, entiendo que probar componentes aislados no es lo más eficiente, pero entonces…

¿Cómo deberíamos probar el frontend? 🤔

Primero, tenemos que entender que el frontend es una interfaz de usuario. Y como tal, deberíamos probarlo como un usuario lo utilizaría.


✨ Cuanto más cercanas sean nuestras pruebas a cómo un usuario utiliza nuestra aplicación, más confianza nos darán. ✨

Es decir, los usuarios no ejecutan el servicio getListaDeCositas() manualmente. Los usuarios interactúan con elementos de nuestra página, los cuales a su vez llaman a otros servicios.

Esto significa que en lugar de probar un servicio de manera aislada, sería más fiable forzar la ejecución del servicio mediante la interacción con el DOM.

La otra gran pregunta es, ¿cuánto abarca una prueba así?

La respuesta es que se pueden usar diferentes estrategias. Esta parte dependerá de tu arquitectura y tus necesidades.

En este caso, renderizamos la página /login y centramos la prueba en cumplir un criterio de aceptación.

// login.test.jsx
describe('Given the user navigates to the login page', () => {
  describe('when the user clicks on "Enviar" button', () => {
    it('should display "Eureca!" message', async () => {
      render(<LoginPage />);

      const buttonForm = screen.getByText('Enviar');
      await fireEvent.click(buttonForm);

      const successMessage = screen.getByText('Eureca!');
      expect(successMessage).toBeVisible();
    });
  });
});

¿Qué ganamos con esto?

  1. 🛡 Más confianza en las pruebas: Las pruebas reflejan cómo los usuarios realmente utilizan la aplicación.
  2. Menos fricción al refactorizar: Las pruebas no se acoplan a elementos aislados, sino que se centran en probar escenarios completos, evitando probar detalles de implementación.
  3. 🌈 Mayor cobertura: Las pruebas no se enfocan en probar un único expect, en su lugar, cubren mucho más código con menos cantidad pruebas.
  4. 📚 Mejor documentación: Las descripciones de nuestras pruebas reflejan mejor la lógica de negocio.

No obstante, no significa que la prueba unitaria aislada esté prohibida, ni mucho menos.

Por ejemplo, para librerías compartidas o lógicas muy complejas, plantearía hacer pruebas unitarias aisladas.

El melón de mutation testing, e2e, performance testing… mejor lo dejamos para otro día. 🥸

Conclusiones

En el mundo del frontend, las pruebas deben ser tan ágiles y adaptables como nuestras aplicaciones. La meta no es solo tener pruebas que funcionan, sino pruebas que resuenan con las experiencias de los usuarios.

Si nos enfocamos en elementos aislados, tendemos a probar detalles de la implementación, haciendo pruebas más frágiles al cambio.

Así que, la próxima vez que escribas una prueba, pregúntate: “¿Esto refleja cómo un usuario real usaría esto?” Si la respuesta es sí, vas por buen camino.

🧪 ¡Feliz testing! 🧪

📕 Referencias

  1. The Practical Test Pyramid
  2. Static vs Unit vs Integration vs E2E Testing for Frontend Apps
  3. Deconstruyendo la pirámide de los tests
  4. Unit Testing is Overrated