Sobre los tests unitarios dependientes de las bases de datos
Resulta obvio decir que una prueba unitaria que involucre una API de terceros será, cuando menos, disfuncional, y hablando con honestidad, una pieza de código que jamás debería integrarse al proyecto. Sin embargo, con las bases de datos, parecería que suele hacerse una excepción.
Antes de continuar, plantaré una premisa simple:
en una prueba unitaria sólo debe probase aquello que, tras ser ejecutado en diferentes entornos, tendrá el mismo comportamiento esperado.
Una aplicación posee esa característica por definición: un conjunto de casos de uso, para cada uno de los cuales se espera una salida exitosa determina, o un conjunto posible de cursos alternativos (casos de error). Desde luego que se parte del supuesto de un sistema estable, por lo que un fallo de memoria o de cualquier otro componente vital se considera un error crítico, que en la mayoría de los sistemas será un error no recuperable. Cuando se trate de sistemas backend, gracias al uso de contenedores, es posible acercar esas diferencias que pueden darse entre los diferentes entornos. En el caso de aplicaciones que dependen de la plataforma del usuario (por ejemplo de escritorio, móviles o front-end), es esperable que éstas no se comporten de la misma forma, y para ello dicha aplicación deberá o bien aplicar “parches” (polyfills, shims, etcétera) o bien limitar la compatibilidad de dicha aplicación a un conjunto específico de plataformas. Generalmente se combinan ambos enfoques.
Sin embargo al tratarse este artículo sobre el impacto de escribir tests que involucren bases de datos, lo más común es que nos estemos refiriendo a sistemas backend, o al menos, a capas de negocio que involucren acceso a datos dentro de los otros tipos de aplicación antes mencionadas.
Analicemos juntos algunos motivos que te harán pensar dos veces en escribir un test que requiera una base de datos.
Son costosos
En términos monetarios: quieres gastar dinero en una instancia de bases de datos en tu pipeline de integración? No sólo el costo de la instancia, sino el costo que requiere su mantenimiento, y el volumen de tráfico que requirere su preparación. En términos de tiempo: ejecutar los tests que involucran bases de datos requiere mucho más tiempo que uno que se ejecuta en memoria, por el tráfico de red, y por su “set up” y “tear down”.
Pueden comportarse de forma no-determinística
Estado inconsistente
Aunque las librerías de prueba hagan su mayor esfuerzo por “limpiar” la base de datos antes de ejecutar una prueba, este proceso puede fallar. Y dependiendo de la suite y del servicio de bases de datos, esto puede ocurrir de forma silenciosa. Del mismo modo, nada impide que otro proceso modifique el estado interno de una base de datos mientras una prueba se está ejecutando.
Implementación incorrecta del test
Si no somos cuidadosos al momento de leer registros de la base de datos, puede ocurrir que estos registros no siempre se lean en el orden esperado, o incluso que ocurra lo mismo con los atributos de cada registro. Esto, en connivencia con conjunto de aserciones muy “laxas”, causará que las pruebas no sean confiables, a veces fallando, otras veces pasando.
Falsos positivos
Una instrucción puede fallar debido a un error del sistema de bases de datos, y arrojar un error. En fase de desarrollo, esto genera una pérdida de tiempo para el ingeniero. En fase de integración, esto puede requerir ayuda del área de operaciones, un nuevo despliegue, la ejecución de un nuevo pipeline… En fin: más tiempo y dinero.
No quieres probar tu base de datos, sino tu lógica de negocio
Algo que se ha dicho hasta el cansancio en diferentes ámbitos académicos. Podrá el lector argumentar que hay errores que surgen precisamente de la comunicación entre los aplicación y la base de datos. Evaluemos estos casos:
Cobertura ante inserciones y actualizaciones
Dado que son comportamientos de integración, por involucrar a la aplicación interactuando con un actor externo (la base de datos) ¿no deberían esos casos ser cubiertos mediante aseguramiento de calidad, sea el ingeniero mismo o éste junto con el área dedicada a tal labor?
Lecturas
Las lecturas suelen ser los casos más polémicos. Debe asegurarse que una búsqueda devuelva los resultados deseados. Sin embargo, considerando que la mayoría de sistemas confían en un almacén dedicado a las búsquedas, como servicios de indexación o réplicas, ¿tiene sentido realizar pruebas unitarias relacionadas con búsquedas? Y en el caso de las lecturas para hidratación de objetos, ¿no aplicaría también el criterio de realizar QA antes mencionado?
Consistencia entre el almacén de datos y el código
Es un problema frecuente que, al actualizar la definición de la base de datos (un esquema, un campo), alguna pieza de código “no relacionada con lo que se modificó” que depende de ésta deje de funcionar o lo haga de forma no deseada. El típico caso de “desvestir a Pedro para vestir a Pablo” (arreglamos A pero rompimos B). Hablaré sobre esto en el siguiente punto.
A fin de cuentas, es un síntoma de un mal diseño
Base de datos como comunicador inter-proceso
Este es un anti-patrón tan antiguo como conocido. Surge como consecuencia de consumir los mismos recursos de un almacén de datos desde diferentes aplicaciones o subdominios dentro de una misma aplicación monolítica. Y, penosamente, esto ocurre en la gran (enfatizo gran) mayoría de sistemas. Desarrollo este punto primero en relación a la ventana que he dejado abierta en la sección anterior. Por qué “rompemos B cuando arreglamos A”? Deberían A y B tener una dependencia funcional sobre los mismos recursos? Sí, ya sé que estás pensando en los dolores de cabeza que te ha causado intentar escribir una consulta de búsqueda sin caer en este anti-patrón, al punto que has pensado que este “anti-patrón” es ridículo, impracticable, y lo has olvidado. Ahora bien, ¿has pensado en volcar estos datos en un almacén secundario dedicado a búsquedas, business intelligence o lo que necesites satisfacer (quizá implementar CQRS)? ¿O al menos hacer una minima implementación de eventos de dominio, y concentrar esos datos en un esquema dedicado a ese fin cuando un registro se actualiza, en caso de no querer incurrir en infraestructura adicional? (Esto es una proyección, un esquema agregado o como prefieras llamarlo, dentro del mismo almacén de datos). ¿No debería cada subdominio ser responsable de un contexto específico del sistema, es decir, propietario, o “hacerse cargo”, de una agregación específica, impidiendo bajo cualquier circunstancia que SUS datos puedan ser alterados sin cumplir las invariantes de negocio que éste define?
La capa de negocio no debería conocer detalles de implementación
Esto, como ya he dicho en otros artículos, no es algo caprichoso. Un almacén de datos es un componente de infraestructura. Como tal, debería ser abstraído mediante un contrato y su materialización debería inyectarse como una dependencia en tiempo de ejecución. De este modo se logra desacoplar “lo definido” de “lo insumido”, siendo lo primero el modelo de negocio, y lo segundo la tecnología de hardware y software subyacentes para hacer funcionar al sistema. Imaginen desarrollar un sistema que sólo funcione con una versión específica de una base de datos, asumiendo el hipotético caso de que no existiera el concepto de retrocompatibilidad: cuán costoso sería actualizar esa base de datos? Lo mismo ocurre con los dialectos o las tecnologías. Es común también creer que un mapeador objeto-relacional nos salvará de esto. Nada más lejos de la realidad. De hecho, un ORM puede forzarnos a agregar una capa adicional en nuestra aplicación o peor aún, estar “atados” al mismo a perpetuidad, para luego comenzar a “doblar” sus reglas al notar que es inefectivo para satisfacer ciertos casos de uso. Y recuerda que las librerías, a la larga, o bien se abandonan, o bien reciben actualizaciones mayores que nos pueden arrojar en el foso del “dependency hell”. Sí, sé que me ganaré el descontento de los fundamentalistas de los ORMs con esto; qué más da. Recuerden que “lo mágico” en la ingeniería de software, tarde o temprano, coloca una muralla en frente del negocio, que impide que éste continúe evolucionando.
¿Quieres probar la consistencia de tu base de datos?
a. Haz pruebas de regresión: sean manuales o automáticas, puedes probar “cómo funciona todo junto” efectuando pruebas periódicas, fuera del pipeline de integración e incluso de despliegue, que aseguren que no sólo la comunicación con la base de datos, sino con demás actores, funcione como se espera.
b. Si en tu sistema la capa de acceso a datos está correctamente aislada, sería posible utilizar la misma, con un poco de astucia, para identificar los cambios en las consultas y solicitar la verificación de que se hayan aplicado los cambios pertinentes en el almacén de datos.
c. Escribe un manifiesto: una versión un poco más “burocrática” del inciso anterior. Haz obligatorio que cada vez que se realice un cambio en la definición de base de datos (una migración) sea necesario además modificar un archivo en la base de código que lo insume. Si quieres ir un paso más allá, agrega a tu pipeline un chequeo mediante análisis estático que detecte inconsistencias entre los datos usados para interactuar con tu base de datos y ese manifiesto. Sí, es básicamente lo que hacen los ORM (que seguramente tendrás en alguna línea de tu pipeline que ejecute los cambios aplicados en el mapeo directamente en la base de datos de producción, muy seguro ¿no?) pero sin tener esta dependencia.
d. Sé creativo: no hay garantía de que alguna de las opciones anteriores se ajuste a tu problema, o simplemente puedes no estar de acuerdo con ellas. Si ese es el caso, ¡usa tu imaginación y gánate un lugar en el corazón de tu equipo! Ten presente que, en el caso de los bases de datos que dependen de definición de esquemas (como las SQL) cuando los atributos no son opcionales y no tienen un valor predeterminado, siempre hay un punto de “vulnerabilidad” entre que se modifica dicho esquema y se actualiza el código que lo insume. Y no existe solución definitiva a este hecho.
En última instancia, un buen diseño a nivel módulo también minimiza el margen de error en la comunicación con el almacén de datos:
- No pases vectores de parámetros no tipados a la DAL, sino un argumento por cada atributo o un objeto de dominio, o al menos uno de transporte (DTO). Y en caso de hacerlo, valida cada argumento antes de consumirlo.
- Minimiza el uso de valores opcionales (nullables): además de ser un indicador de des-normalización, incrementan el riesgo de olvidar pasar argumentos en los métodos consumidores, lo cual disminuye el grado de determinismo del método.
Conclusiones
Las pruebas unitarias deben asegurar que las invariantes de negocio se cumplan, es decir, que ante una entrada X el sistema produzca una salida Y, y qué devolver cuando estas invariantes no se cumplan. Nada más.
Las bases de datos son componentes de infraestructura, y pueden cambiar a lo largo del tiempo según las necesidades del negocio (innovación, costos, decisiones políticas). No son el sistema. No deberían limitar las decisiones de diseño del sistema. Y por ello, el código que interactúa con ellas debería estar lo más lejos posible del núcleo de la aplicación (el dominio de negocio).
Y el cambio de una tecnología de bases de datos debería involucrar cambios en una única capa del sistema; en caso de propagarse a otras capas, estamos frente a un error de diseño. Soltemos de una vez por todas la idea de “database-centic architecture”; sí, otro anti-patrón, pero éste sí que ha llenado los bolsillos de las compañías propietarias de tecnologías de bases de datos. Más aún hoy en día, teniendo todo un zoológico de opciones de almacenamiento para elegir.