Test’them all! Breve aproximación al clean testing

Muy contentos/as y orgullosos/as con la acogida de nuestro anterior post, continuamos esta serie de entradas relacionadas con el desarrollo de software y TDD, con el foco puesto en las prácticas y valores que lo componen. Hoy nos gustaría hablar sobre testing, especialmente sobre cómo mantener nuestros test limpios y claros para que aporten todo su potencial como documentación viva del proyecto. Hay muchos recursos de referencia y muy buenos. En este artículo trataremos de exponer las cosas que nos funcionan a nosotros/as y encontramos interesantes, siendo conscientes de que no son dogmas válidos para todos los contextos.
Finalmente, comentaremos los principales code smells que nos encontramos en nuestro código de testing. ¡Empecemos!
Un buen nombre lo es (casi) todo
Una de las ideas claves que se pueden extraer de la lectura de Clean Code , es que los nombres importan y mucho. De igual forma que pensamos mucho el nombre que le ponemos a las personas y cosas de valor, porque entendemos que le aporta significado y entidad, tenemos que invertir tiempo en nombrar bien nuestro código. Es posiblemente una de las tareas más difíciles, pero recuerda: no hay un mal nombre que un simple refactor no pueda solucionar, sobre todo si se hace al día siguiente, con la mente fresca.
Sobre las estrategias de nombrado de test, hay muchas, puedes ver algunas aquí . Lo importante es que el equipo en consenso acuerde cuál seguir y ser fieles a dicho consenso. A nosotros/as nos está encajando muy bien la forma:
«clase»_Should.«nombre_del_test»
Es decir,
UserService_Should.register_new_user()
Mediante el uso de la palabra “Should” intentamos visibilizar las responsabilidades que tiene esa clase, y que vamos a testear. Evitamos el uso del sufijo “test”, porque ya sabemos que es un test, está en una clase separada siempre y tiene la anotación encima. El resultado final, es que cuando pasamos las pruebas y el framework de testing lista todos los test, es muy fácil leerlos como si de criterios de aceptación se tratase. Algunos principios muy básicos que nos ayudan son:
- Establecer un vocabulario común, que indique cuándo usar palabras que pueden tener varios significados dentro de un contexto, por ejemplo: get, return, retrieve, fetch…
- Usar siempre guiones bajos: Los test no se van a llamar desde otras clases, ni son tampoco parte de una API pública por lo que los guiones bajos facilitan la lectura.
- Explicarlo en términos funcionales: Nos ha dado mejor resultado, en la mayoría de los casos, explicar funcionalmente lo que probamos y no técnicamente, especialmente con los algoritmos. Recuerda que el objetivo debe ser probar el comportamiento.
- Mostrar la intención, aunque queden nombres largos.
Fíjate que hablamos de estrategias de nombrado, en líneas generales. No es muy recomendable intentar buscar la fórmula mágica infalible para nombrar todos tus test porque no existe, de hecho todo esto es muy subjetivo, pero creemos que precisamente es positivo dejarlo parcialmente abierto y que el propio equipo vaya encontrando su forma de nombrado.
Como sabemos el software está vivo y cambia constantemente. Aparte de los motivos más obvios, es posible que en un evolutivo, haya funcionalidades que cambien o se eliminen y de igual modo lo hagan nuestros test, por eso también es muy importante que sean fáciles de identificar y localizar. Por cierto, recuerda que cuando te ocurra esto, debes borrarlos y no debes comentar tu código de test porque crearás código zombie y para no perder esos test ya tienes el SCM.
Arriba, la peor forma posible de nombrar: Test1, test2, test3…
La estructura de un test
Sobre la estructura que tiene que tener un test hay bastante consenso. Puede depender un poco del framework de testing y del lenguaje, pero en general siempre se busca que haya tres bloques: preparación, ejecución de la acción que se quiere probar y el assert o resultado esperado. A continuación se muestra un ejemplo de estructura con los bloques tipo describe-it (Rspec) que usamos sobretodo en Typescript o JS.
describe("The <<component_to_test>>", () => { it("should meet some rule", () => { // arrange or given // act or when // assertion or then }) })
Para otros tipos de lenguajes, sería análogo. En general facilita muchísimo la lectura tener las líneas en blanco entre cada bloque. Si bien cada uno de esos bloques no tiene por qué ser una línea, esto sería lo ideal; pero de eso hablaremos un poco más adelante.
Propiedades del test
En Diseño Ágil con TDD se describen los principios y propiedades básicas de los test. A modo de resumen, nos indica que todo se trata de un compromiso entre ciertos atributos (velocidad, granularidad, inocuidad, expresividad, acoplamiento, fragilidad…). Respecto a esto, creo que puede ser muy útil el acrónimo FIRST
- Fast (rápido)
- Independent (independiente)
- Repeatable (repetible)
- Self-validated (auto-validado)
- Timely (oportuno)
En cuanto a los tipos y categorías de test, curiosamente es bastante habitual, al menos en nuestra experiencia, que cada equipo categorice de forma distinta sus test: lo que para unos/as es integración, para otros es aceptación o end2end. Realmente, a nosotros/as ese debate no nos aporta demasiado. Lo que nos funciona es tener muchos test unitarios, tal y como indica la pirámide de Cohn y entendiendo por unidad un bloque con una responsabilidad concreta.
Por ejemplo, para un backend hecho con Spring, tenemos unitarios de todas las capas y objetos derivados, usando mocks y stubs para aislar y que realmente sean unitarios (cuidado, si tienes muchos mocks es que tus clases podrían estar poco cohesionadas), y luego algunos test de integración. Fíjate que digo algunos; básicamente a nosotros/as nos funciona basar todo el testing sobre decisión y comportamiento en unitarios. La única información que nos aporta “integración” es que las piezas encajan, pero no “comportamiento” en sí. De hecho, es muy habitual que si un test de integración nos falla, nos falle también uno unitario. Cada uno por sus motivos, lógicamente.
Sobre el testing en front, hablaremos en otro capítulo 😉 pero rige exactamente el mismo juego de responsabilidades.
Fíjate también que en ningún caso desaparecen los test exploratorios manuales, porque ni la mejor cobertura exime de errores. Siempre es bueno hacer pruebas exploratorias y, de hecho, hay equipos que se encargan solo de esto, y lo hacen muy bien.
Responsabilidad única, condición única de fallo
Y sobre responsabilidades, no hay nada como la responsabilidad única. Si decimos que en nuestro código de producción queremos que una unidad (clase, método, etc) haga una única cosa, y decimos también (sin duda) que nuestro código de test es código de producción, entonces nuestros test tienen que probar una única cosa. Ojo, probar una cosa, no siempre significa tener un único assert por test. Esto, que es ideal, no tiene necesariamente por qué ser así, sobretodo teniendo en cuenta que si el assert tiene descripción, con las herramientas de testing modernas cuando se produce un error ya nos lleva directamente al assert que falló, indicándonos también el motivo. A pesar de eso, por norma general es mejor ir con la mente hacia un único assert por test.
En jUnit existen los test parametrizados, que en el fondo es un mecanismo para evitar tener muchos test iguales donde solo cambian los valores a probar. Fíjate que la clave es que realmente se prueba siempre el mismo comportamiento (responsabilidad única) ante distintos estímulos.
Si probamos una única cosa, entonces debe fallar por una única cosa (en términos de comportamiento). Esto es vital para identificar el origen del fallo y poder arreglarlo. De lo contrario, acabaremos con código de test más complejo que el propio código de producción.
Sobre duplicidad: DRY y DAMP
Seguramente hayas escuchado mucho que en los test hay que ser DAMP (Descriptive And Meaningful Phrases) y no DRY (Don’t repeat yourself), es decir, hay que ser más descriptivos aun a costa de tener duplicidad de código.
Si bien encuentro la afirmación acertada, hay que tener presente que realmente DRY se refiere más bien a no duplicar conocimiento de dominio, y no tanto a líneas de código en sí. Es por eso que es normal ver en los test código técnicamente repetido, sobretodo de inicialización; porque aunque el código sea el mismo el dominio no lo es, ya que cada test tiene el suyo propio necesario para probar.
A mayores, dotamos de independencia respecto a otros test. Esto no significa que no haya bloques de inicialización que se puedan mover a bloques @before. De hecho, es recomendable para que los test queden más claros, pero siempre teniendo presente el dominio, ya que dos líneas de código iguales no necesariamente aportan el mismo significado a dos test distintos.
Para profundizar en este topic tan interesante, te recomiendo la siguiente lectura donde se explica muy detalladamente porque la dicotomía DRY-DAMP realmente no existe y lo que se debería es buscar un balance.
Algunos code smells
Como apartado final, os dejamos una lista de los principales code smells que nos ayudan a identificar acciones de refactor, principalmente en nuestros test. No dejes de echarle un ojo al libro o a la página de xUnitPatterns .
página de xUnitPatterns también hay una lista muy completa, y con ejemplos.
– Hard to test Code
Probablemente uno de los smells más común. Cuando no sabemos cómo probar algo, es porque seguramente o no sabemos lo que hace, o es demasiado complicado, o hace muchas cosas o todo lo anterior. En definitiva, aquí lo que hacemos es un refactor que busque dividir responsabilidades. Afortunadamente, con TDD este tipo de situaciones no se propician tanto. Es posible que inicialmente no tengamos mucha idea de cómo enfrentar un algoritmo o no sepamos cómo probar algo, pero la propia técnica, al ir pasito a pasito, te va abriendo poco a poco los ojos y desvelando el algoritmo. Si además haces refactor en cada ciclo como dicta TDD, es difícil que acabes paralizado por esta situación.
– Erratic Test
Cuando un test funciona y falla por igual de forma aparentemente aleatoria. Lo que se aconseja aquí es recoger mucha información acerca de dónde y cuándo falla para intentar ver un patrón. Normalmente, cuando pasa esto suele ser porque el test no es independiente respecto a otros test o mismo test suites, o bien hay una carrera sobre un recurso compartido. Ambas nada deseables. Con las herramientas modernas se hace mucho más sencillo ver este tipo de conflictos, pero pueden ser un verdadero dolor de cabeza.
– Conditional Test Logic
En los test no debemos poner condicionales, si tienes que ponerlos, es mejor transformarlo en varios test sin condicionales. Ganarás claridad, simplicidad, robustez…
– Constructor Initialization
Recuerda, si necesitas preparar algo para los test, siempre es mejor usar los bloques @before que proporcionan los frameworks de testing que usar los constructores de la clase, ya que estos bloques se ejecutan entre cada test o al principio de todo. Si el framework no tiene este tipo de mecanismos, es mejor crear un método ad-hoc para preparar los test.
– Exception Handling
Un code smell recurrente, cuando vemos en nuestro test bloques try-catch para controlar las excepciones, se hace difícil entender este tipo de test y la mayoría de los frameworks incluyen ya anotaciones o mecanismos para indicar que un test va a producir, o no, una excepción, por lo que son innecesarios y además son formas encubiertas de controlar flujos en un test.
– Magic Numbers
Este smells probablemente sea uno de los que más vemos. Cuando un test contiene literales numéricos, normalmente nos quedamos un poco paralizados pensando que podrá significar ese “número mágico”. A lo mejor en su momento lo teníamos claro, pero ha pasado tiempo y como es algo próximo a negocio nos olvidamos. Siempre es recomendable refactorizarlo a un nombre con significado. Si extraes una constante para dar significado, recuerda que normalmente va a ser mucho mejor que lo hagas dentro del scope del propio test y no global, por lo que comentamos anteriormente del dominio.
Por último, recuerda que el peor smell es no hacer test, por eso el mensaje para concluir este post debe ser siempre escribe test, sea como sea, pero hazlo 🙂
Esperamos que el post os haya aportado algo de valor. ¡En los próximos nos mancharemos las manos con un poco de código! Os invitamos como siempre a que nos comentéis vuestra experiencia, dudas o críticas, que siempre son bienvenidas. Un saludo y ¡Happy testing!