No dejemos nuestros tests tirados
Que nuestros tests también sean objetos
En este artículo voy a asumir que ya entendemos la importancia de escribir tests como una parte integral del desarrollo, y voy a hablar de las consecuencias de pensar a los tests como objetos de dominio. A quien no conozca el patrón Method Object, tiene sentido googlearlo o, por ejemplo, leerlo de aća: Smalltalk Best Practice Patterns.
La idea es discutir cuáles son las limitaciones de no modelar un caso de test como un objeto de dominio, y la importancia de que consideremos a nuestro ecosistema de tests como parte de nuestro código, lo cual implica que debe ser mantenido y modelado con el mismo cuidado que el resto del sistema.
Voy a considerar un test como un programa compuesto por 3 partes:
- Given: donde se crean todos los objetos y condiciones de corrida del test. Es decir, el contexto del test.
- When: donde se ejecuta el código que se quiere testear
- Then: Donde se realizan aserciones (pocas, e idealmente una) sobre el estado del sistema luego de ejecutar el código. Esto va desde “este número tiene este valor” hasta “se levantó una excepción con este mensaje en particular”.
Por ejemplo, últimamente suelo programar en Python. Normalmente cuando escribimos tests usamos un paquete del estilo de unittest, que organiza los tests usando:
- Una o varias clases que representan test suites
- Tests individuales escritos en distintos métodos de estas clases, que cumplen la convención de tener un nombre que empieza con la palabra clave test.
- El código en común que tienen los tests para iniciarse y terminar se pueden abstraer en los métodos de setup y teardown.
Esto es muy cómodo para muchos casos, y naturalmente agrupa tests que tienen setups parecidos dentro de la misma clase. Pero vamos a intentar hacerle algunas modificaciones y ver qué pasa.
Casos que desafían los límites del modelo
Vamos a suponer algunos casos de uso para pensar en la robustez del modelo:
- Quiero registrar cuánto tarda un test en correr y escribirlo en un log: si es un solo test, puedo guardar un timestamp cuando empieza y uno cuando termina, y después imprimir la diferencia. Si empiezan a ser más tests (incluso dentro del mismo test suite) necesito o bien repetir código, o bien crear un método que mida el tiempo de ejecución de un método, y usarlo para invocar los métodos decorados (iterando closures, escribiendo uno por uno a mano o usando metaprogramación).
- Quiero correr un test con muchas aserciones diferentes: una opción es escribir todas las aserciones en un mismo test, una después de la otra, pero esto tiene la desventaja de que cuando la primera falla, el test se corta y no tengo un mapa de exactamente qué está fallando. Si quiero separar las aserciones en distintos tests, puedo poner el given y el when en el setup, pero sería poco declarativo.
- Quiero correr un mismo test para contextos (given) diferentes: aún si reificamos el contexto de ejecución, un framework como unittest no permite pasarle parámetros a un método de test de forma limpia con lo cual necesitamos repetir código o guardar los contextos dentro de la clase de test suite. El problema llega cuando queremos hacer esto para varias test suites a la vez.
De fondo, lo que tienen en común estas situaciones (y limitaciones) es que no hay lenguaje para hablar “acerca de” los tests porque son métodos, en vez de ser objetos sobre los que se puede predicar. No se puede pasar “un test como parámetro” (sin usar metaprogramación) para saber cuánto tarda o cuantas veces se corrió, ni se puede conceptualizar “correr el mismo test en otras condiciones” porque no existe la idea del “mismo test”.
La propuesta
Supongamos que tenemos una clase TestCase con un método run_test(). Es decir, cada test es un objeto. Vamos a ver cómo se transforman las distintas situaciones:
Situación 1:
Si quiero puedo crear un Decorator (otra cosa para buscar en Smalltalk Best Practice Patterns) TimedTestCase que tenga un TestCase como colaborador interno y que implemente run_test() como:
class TimedTestCase:
def __init__(self, test_case):
self._test_case = test_case def run_test(self):
timer = Timer.start() result = self._test_case.run_test()
timer.stop() timer.print_time_difference()
return result
(asumiendo por supuesto que la clase Timer funciona como esperamos). Sin repetir código, todos mis tests ahora pueden loguear cuánto tiempo tardan.
Situación 2:
Supongamos que no hay aserciones sobre excepciones por simplicidad, pero también se puede hacer usando un try. Voy a asumir también que hay un objeto que representa el sistema, y otro que representa el valor de retorno de la ejecución del when. O sea que para testear method_to_test() escribimos:
class TestCase:
def __init__(self, system):
self._system = system def run_test(self):
# Given this particular setup
...setup methods
# When I test this method
return_value = method_to_test(parameters) # Then the assertions are correct
self._verify_assertions(retun_value) def _verify_assertions(self, return_value):
# Here I can use self._system to check for collateral effects
raise NotImplementedError(“Subclass responsibility”).
…y generamos subclases con las distintas aserciones para poder testearlas por separado. Incluso mejor, se podrían modelar a las mismas aserciones como un objeto, y pasarle a distintas instancias de la misma clase el método de test y las aserciones como parámetro, así no hay que crear una clase aparte para cada aserción.
Situación 3:
Usemos la clase TestCase de la situación 2. Se puede generar una subclase que tome un parámetro más al inicializarla y que guarde las diferencias entre los contextos como colaboradores internos. Por ejemplo, para testear un comportamiento en un sistema para perrxs y gatxs, se escribe:
dog_test = TestFeatureForAnimal(system, Dog(...))
cat_test = TestFeatureForAnimal(system, Cat(...))
Como corolario, es muy fácil saber la lista completa de casos de test, y no hay que mirar los nombres de métodos para ver si empiezan con test o no.
Ventajas del approach
La principal ventaja de esta forma de encarar el problema es que ataca el problema de fondo. Entiende a los tests como ciudadanos de primera clase de nuestro ecosistema de objetos.
Cuando programamos, modelamos objetos de diversos dominios, pero muchas veces olvidamos que nuestros tests también son un dominio con sus propias particularidades, y que vale la pena pensar la forma en la que los modelamos para que mantener este segundo ecosistema no sea una tarea ardua.
Cuando se tiene un buen ecosistema de tests, expresar el comportamiento de features nuevas es mucho más fácil, rápido y declarativo, y lleva menos tiempo y energía mental escribir en ese marco. Esto no sólo se consigue haciendo que los tests sean objetos, pero es un buen paso en esa dirección.
Se abre la discusión
Voy a cerrar con tres discusiones (no digo que sean las únicas) respecto de este approach, porque esta no es una idea cerrada y vale la pena pensar en conjunto cómo modelamos tests.
Primero, se puede decir que la ejecución es más lenta en ciertos casos, cuando no se aprovecha el mismo test para correr varios asserts (en vez de hacerlos en tests distintos) o bien cuando el setup de un test es muy pesado y se replica. Esto es verdad, nos pasó cuando implementamos esta solución en el proyecto en el que trabajo. Creemos que igualmente vale la pena pensarlo y que es posible escalar horizontalmente la corrida de tests para mitigarlo. Además, una buena parte de los tests se pueden acelerar alivianando el setup (por ejemplo usando tests en memoria que no hablen con las BBDD).
Otro argumento es que decorar e implementar varias cosas sobre los tests a la vez puede generar una explosión combinatoria de clases que se vuelva inmanejable. No exploramos este camino pero me parece que vale la pena intentar resolver cosas por composición o usando Mixins (o algo similar en cada lenguaje) para paliar esto. Hay que hacerlo y ver cómo queda para seguir iterando sobre el modelo, ya que esto también es una complejidad real que hay que afrontar.
Otro problema es que muchas veces escaparse del modelo que usa nuestro framework, integrado con nuestra IDE implica hacer hacks incómodos y no aprovechar las herramientas del framework (o en el peor caso tener que reescribir cosas). Esto puede ser una desventaja en el corto plazo, o en la escala individual o de un grupo pequeño. Por otro lado, es parte de un problema más profundo, y si no nos acostumbramos a poner a prueba nuestros frameworks y levantar la cabeza para repensar ciertas cosas de base, nos perdemos el sueño (como comunidad de programación) de programar nuestros propios frameworks como queramos, y es una pena.