Cómo escribir tests rentables
Por
Carlos Blé Jurado
Los tests automáticos son una herramienta que nos permite ganar velocidad de desarrollo
y evitar problemas de mantenimiento severos, siempre y cuando sean adecuados. Las personas que no
tienen suficiente experiencia escribiendo tests, a menudo ignoran el gran impacto que la
calidad de los tests tienen en el ciclo de vida del producto. El código de los tests
requiere el mismo nivel de cuidado que el código que se despliega en producción, no se trata de código
de segunda clase. Escribir tests requiere una inversión de tiempo; recuperarla o no, depende de
la calidad de los mismos. Si quieres que tu proyecto siempre huela a “green field”, necesitas
buenas y abundantes baterías de pruebas.
¿Cuáles son los principios que buscamos en los tests?
Un libro relativamente actualizado y muy bueno sobre los tests es Effective Unit Testing
de Lasse Koskela, que pese al nombre
“unit” aplica muy bien a “integration” tests también.
Cuando escribo tests, busco un balance entre los pros y los contras que me ofrecen los distintos tipos de
tests automáticos que existen. Los principios que sopeso son:
- Legibilidad: quiero que mis tests tengan como máximo tres líneas (act, arrange, assert) y que me cuenten nada
más que los datos mínimos relevantes que necesito ver para entender y distinguir cada escenario. Sin datos superfluos, sin
ruido. Quiero que los nombres de los tests me cuenten cuál es la regla de negocio que está cumpliendo el sistema,
no que me vuelvan a decir lo que ya puedo leer dentro del test. No quiero redundancia.
- Feedback: ¿Es correcto el código? ¿Funciona? ¿Cuánto tiempo tardan en darme una respuesta? Cuando fallan ¿cómo de entendible es el error?, ¿cuánto me cuesta encontrar la causa del problema? Cada vez que siento la necesidad de levantar la aplicación, entrar en
ella como usuario y probar la funcionalidad a mano para ver que está bien, siento que necesito tener un test que haga eso
por mí. Está bien que lo haga una vez, pero no continuamente. Es una pérdida de tiempo enorme estar haciendo esa
operación manual decenas de veces al día. Depurar es una de las estrategias más improductivas y tediosas que existen.
La rentabilidad de los tests se produce porque dejamos de malgastar muchas horas diarias en compilar, desplegar, crear
datos, probar a mano…
- Simplicidad: ¿cuántas dependencias tienen mis tests? - librerías, frameworks, bases de datos,
otros servicios de terceros… ¿cuánto cuesta entender cómo funcionan todas esas piezas?
- Resiliencia: cada test debería romperse solo por un motivo, solo porque la regla de negocio que está ejercitando
ha dejado de cumplirse. Si el test tiene un ámbito tan amplio que es inevitable que se rompa por varios motivos, entonces
necesito añadir tests de ámbito más reducido que me ayuden a entender la causa del problema. No quiero que los tests
se rompan cuando cambio algún detalle que no altera el comportamiento del sistema, como el aspecto de la GUI.
- Flexibilidad: quiero poder cambiar el diseño de mi código (hacer refactor) sin que los tests se rompan, siempre y
cuando el sistema se siga comportando correctamente. Los tests no deben impedirme hacer refactor, no deben ser un lastre.
Concretando, lo que busco es:
- Que mis tests se ejecuten lo más rápido posible.
- Que pueda lanzarlos de forma cómoda en cualquier momento.
- Que pueda elegir lanzar diferentes suites de tests, no siempre necesito ejecutarlos todos.
- Busco el grado de granularidad del test más adecuado en cada momento.
- Mis tests atacan al código de producción como una caja negra, como un sistema. Aquellos tests que prueban la
lógica de negocio (típicamente, código con condicionales y cálculos) suelen tener una granularidad menor, porque
lo que están probando es ya suficientemente complejo. Por otra parte, los tests que buscan validar la integración
de varias capas del sistema suelen tener una granularidad mayor.
- Evito lo más posible el uso de mock objects, porque esto me obliga a conocer mucho la implementación y puede llegar
a hacer muy complejos los tests. Prefiero atacar a bases de datos reales, endpoint reales, etc., cuando el feeback
es suficientemente rápido y las herramientas me lo ponen fácil para hacerlo. Si atacar piezas reales resulta que va
a disparar la cantidad de motivos que pueden romper mis tests, entonces me planteo usar mocks (dobles de prueba).
Ejemplo de tipos de tests que haría en una aplicación web con un backend tipo API Rest hecho con SpringBoot y un frontend
hecho con React:
- Tests de extremo a extremo (end2end): Levantan un browser (típicamente Webdriver) y envían comandos JavaScript a la API programática de mi
app JavaScript (esto daría para otro post), o bien manejan la app mediante la GUI. Son los más lentos y requiren montar un sistema lo más parecido
posible al de producción, por lo tanto, de estos tendré pocos. Me dan seguridad y son una bala trazadora para practicar
el doble ciclo de Outside-in TDD. Cuando uno de estos falla, quiero tener otro de grano más fino que me ayude a
entender dónde está el problema.
- Tests de integración:
- Backend: Los tests rellenan la base de datos con lo mínimo necesario y atacan a la API Rest real sin mockear nada, validando
que recibo el JSON que espero. Con las herramientas de tests de SpringBoot es muy fácil de hacer, el feedback es rápido
y cuando algo falla es fácil saber en qué capa está el problema. Aquí las herramientas y la potencia de la máquina
juegan un papel muy importante.
- Contrato de la API: cuando existe complejidad en la capa “controller”, en el enrutado, sus parámetros o cualquier
otro motivo que me pida tener feedback rápido sobre si la configuración de la capa REST es correcta, entonces
ataco al controller mediante un cliente http, usando mocks para todo lo que hay detrás (capas de servicios, base de datos…).
Esto me permite validar la vigencia del contrato, al estilo de como haríamos con las herramientas de prueba de OpenAPI.
- Frontend: Pruebo la página atacando al componente padre con todos los componentes hijos reales montados. Igualmente,
las herramientas hoy en día me lo ponen fácil. El feedback es rápido y esto me permite también hacer Outside-in TDD en
el frontend.
- Estos tests no van a romperse cuando vaya refinando mi diseño, añadiendo más capas por en medio, más clases o componentes.
- No prueban combinatoria de casos, prueban que el pegamento funciona.
- Tests unitarios de negocio:
- Típicamente clases y funciones que hacen cálculos, toman decisiones. Cuanto más cerca del código que tiene la lógica mejor.
- Ideales para desarrollar con TDD, me permiten practicar el diseño emergente.
- Siempre que necesito feedback rápido sobre una pieza en concreto, sea un componente en frontend o una clase en backend,
añado un test rápido.
- No necesitan recrear el sistema, solo una de las piezas.
- Son baratos, rápidos y se encargan de probar la combinatoria de casos posibles que pueden darse en mi lógica de negocio.
Por tanto, tendré muchos tests unitarios, menos tests de integración y menos tests end2end.
No es importante para mí debatir sobre si “integración” significa que solo atraviesan una clase o varias clases,
lo importante es que hagan el mejor balance posible de las cualidades que buscamos en los tests. Tampoco encuentro
valor en discutir si “unitario” significa que solo ejecuta una función o varias, ya que el foco está en el comportamiento
observado por fuera del sistema y no en la implementación.
En cuanto a los tests de “aceptación”, cuando se refieren a validar que las reglas de negocio se cumplen, de este artículo
puede entenderse que, en nuestro caso, muchos tests de aceptación serán unitarios. Existe la idea de que
aceptación son tests end2end, pero no tiene por qué ser así. De hecho, mi recomendación es que haya pocos tests end2end.
¿Cómo son tus tests? ¿Qué valoras de ellos?