En el anterior post explicaba distintas estrategías para testear un API Web y, en los comentarios, GreenEyed planteaba que a veces la parte más complicada es preparar una base de datos adecuada para ejecutar los tests.
A lo que se refería Daniel no era tanto a la parte «mecánica» de crear la base de datos y el esquema, que es algo relativamente fácil de hacer hoy en día si estás usando cualquier ORM, sino a cómo cargar en la base de datos la información necesaria para cada escenario de test.
Como siempre, existen varias formas para conseguirlo, cada una con sus ventajas e inconvenientes propios, e incluso es habitual que dentro de una misma aplicación coexistan diferentes métodos en función del tipo de test que estemos escribiendo.
En este post vamos a analizar unas cuantas para ver qué nos pueden aportar. No voy a entrar en detalles de implementación porque dependen de muchos factores (si usas un ORM o no, la arquitectura de la aplicación, el estilo de tests, etc.), pero seguro que no te cuesta mucho adaptar las ideas a tus necesidades concretas.
Inserción manual en cada test
Este es el caso más sencillo y directo. Para cada test, en su SetUp
(o equivalente), escribimos el código necesario para insertar en la base de datos todo lo necesario.
Si tenemos una misma clase para lanzar los tests, es habitual que haya parte de la información que podamos compartir entre tests, por lo que es recomendable separar esta información de la específica de cada test para acelerar un poco el tiempo de ejecución.
Por ejemplo, si estamos testeando un modelo de facturación (un ejemplo tan típico y conocido como aburrido) y queremos validar que podemos buscar facturas por distintos criterios, necesitaremos guardar en cada test diferentes tipos de facturas que nos permitan testear la lógica de búsqueda, pero además, para crear las facturas probablemente necesitemos otros elementos como clientes, productos, etc.
Estos otros datos «de referencia», que no deberían modificarse entre unos tests y otros, podemos guardarlos durante lo que en NUnit sería el TestFixtureSetUp
, es decir, el código que se ejecuta una única vez antes de lanzar todos los tests de una clase.
Luego, en el SetUp
de cada test podríamos borrar la información generada por cada test, en este caso las tablas de Facturas y Líneas. Hacerlo en el SetUp
en lugar de en el TearDown
tiene una ventaja muy importante, y es que si falla el test tendremos todavía en la base de datos la información que hemos usado en el test y podremos inspeccionarla fácilmente.
Para realizar las inserciones en la base de datos tienes varias alternativas. Puedes pasar por tu capa de persistencia y aprovechar tus repositorios, DAOs, ORM, MicroORM o lo qué estés usando. También puedes pasar de todo y lanzar directamente SQLs contra la base de datos. Al final depende de con lo que te sientas más cómodo, pero en general, si ya tienes un API (a través de repositorios, funciones o lo sea) para guardar información, suele ser más cómodo usarla.
En el anterior post recomendaba usar tests de integración para testear un API Web y emplear un cliente real para lanzar las peticiones. Eso plantea una situación un poco extraña, porque por un lado vamos a acceder al sistema «por fuera», a través del API Web, y por otro queremos utilizar partes internas, como los modelos, repositorios, DAOs, etc., para cargar información en la base de datos. Esto es relativamente fácil de conseguir si has aplicado correctamente inyección de dependencias y puedes reutilizar partes del sistema (concretamente, la capa de persistencia) fácilmente en otros contextos (concretamente, los tests).
La ventaja de este tipo de tests es que no hay que pensar mucho. Necesito insertar cosas y las inserto. Y luego opero con ellas y obtengo un resultado. Fácil y directo, como decía antes.
El principal problema es que se hacen complicados de mantener, sobre todo por la parte de datos maestros, de referencia o como sea que llames a esas cosas que, realmente, no necesitas para el test, pero hacen falta para contentar a tu modelo.
El País
que tienes que crear para poder asociárselo al Cliente
que tienes que crear para poder asociarlo a la Factura
que quieres crear, pero que también necesita un Producto
con su Categoría
, su Impuesto
y no sé cuantas cosas más.
Al final, para validar que se están filtrando correctamente facturas por fecha (por poner un ejemplo sencillo), necesito crear un montón de objetos (registros en la base de datos). Para complicar más las cosas, cuando en un futuro introduces una nueva relación obligatoria, por ejemplo, la Tarifa
asociada al cliente, tienes que ir a todos los tests que hayas escrito para añadirle la tarifa a los clientes o fallarán por violar una restricción de la base de datos.
Partir de datos de referencia
Una forma de solventar el problema anterior es tener algo que nos permita cargar datos de referencia en la clase y poder acceder a ellos cómodamente desde el código. Algo similar a lo que sería un ObjectMother, pero con objetos que están almacenados en la base de datos en lugar de en memoria.
Esto nos permitiría escribir código parecido a éste:
[TestFixture] public class SearchInvoiceTest { private DBData db; [TestFixtureSetUp] public void TestFixtureSetUp() { // Prepara una base de datos vacía CreateEmptyDB(); // Esto *guarda* en la base de datos // los datos de referencia. Si te pone // nervioso meter lógica en un constructor, // sácala a un método LoadDB() o a lo // que me más te guste db = new DBData(); } [Test] public void SearchByDate() { // Puedo acceder a datos varios // para preparar las facturas que // necesitaré durante este test var customer = db.Paco; var product = db.Camiseta; var otherCustomer = db.Marcelo; // Hacer cosas con ellos // ... } }
La ventaja de este sistema es que los tests quedan más legibles porque quitamos mucho ruido de construir cosas que no vamos a utilizar realmente. También conseguimos independizarlos más de las APIs que no utilizan. En el caso que ponía antes de tener que añadir una Tarifa
a cada Cliente
, sólo habría que tocar la clase DBData
y aquellas que creen clientes directamente, que no deberían ser muchas.
Una desventaja a tener en cuenta es que estamos separando la definición de datos de su uso, y a veces puede ser importante. Por ejemplo, si tenemos creados un par de clientes, uno VIP y otro no, tenemos que empezar a jugar con los nombres para asegurarnos de que en los tests queda claro cuál es cuál.
Son los problemas típicos del uso de ObjectMother que podemos resolver con Builders, pero en este caso es un poco más complicado porque los Builders suelen «inventarse» datos y aquí necesitamos que todo sea consistente y esté bien guardado en la base de datos para evitar errores de integradidad referencial.
Tampoco es necesario, ni recomendable, utilizar este sistema en todos los tests. Si tenemos un test que necesita crear muchos tipos de clientes distintos, en lugar de complicar el objeto DBData
con todos ellos, seguramente sea mejor para ese test concreto insertarlos a mano.
La información que está en DBData
debería considerarse inmutable. Esto es importante porque, si sigues el esquema del código del ejemplo anterior, va a ser compartida por todos los tests de esa clase, y si la andas modificando tendrás problemas. Además, si estás usando un ORM, estos objetos no estarán asociados al UnitOfWork
por lo que no serán persistidos automáticamente. Si no los estás tocando y sólo los usas para crear asociaciones con otros objetos, esto no debería ser un problema (al menos en los ORMs que conozco).
Cargar una base de datos de ejemplo
Hay aplicaciones en las que se puede generar un conjunto de datos de ejemplo para, por ejemplo, hacer pruebas, ejecutarlas en modo demo y, en general, permitir a un usuario jugar con ellas sin tener que «picarse» toda la información para empezar a hacerlo. Tal vez no sea lo más habitual, pero es muy recomendable. Aunque sólo sea para hacer pruebas durante el desarrollo, merece la pena.
Si tu aplicación tiene un sistema de estas características, puedes aprovecharlo para escribir los tests de integración. Puedes lanzarlo y usarlo como punto de partida «estable» en los tests.
Tiene la ventaja de que este conjunto de datos suele ser más completo que el que incluiríamos usando la técnica anterior, lo que nos permite hacer tests más variados. También estamos reutilizando algo que ya existía, por lo que nos ahorra algo de trabajo con respecto a la opción anterior.
A cambio, estamos separando aún más la definición de los datos de la definición de los tests, lo que dificulta la comprensión del test. Además, estos datos no se utilizan sólo para los tests, por lo que pueden cambiar por otros motivos, por ejemplo, porque queremos introducir datos que demuestren otra funcionalidad de la aplicación durante las demos, y eso puede llevar a fallos en los tests no previstos.
Este tipo de datos los suelo utilizar cuando la aplicación ya tiene una funcionalidad bastante estable, lo que hace que los datos se modifiquen menos. Tampoco los suelo usar en demasiados tests, limitando su uso a tests de extremo a extremo en los que necesito levantar una aplicación completa, por lo que usar ObjectMother
se me complica en exceso al tener que guardar mucha información, haciendo que el ObjectMother
sea difícil de manejar.
Utilizar una imagen de una base de datos de producción
Debo reconocer que esto no lo he hecho nunca para pruebas automatizadas, pero es una de las alternativas que mencionaba Daniel López en su comentario.
Tiene la ventaja de que estas trabajando con datos reales. Muy reales. Tan reales como que los has sacado directamente de producción.
El principal problema que le veo (e insisto, nunca lo he usado así que es un juicio a priori) es que me parece difícil saber exactamente qué hay en la base de datos, y analizar lo que debería pasar al ejecutar nuestros escenarios de tests es complicado. Saber por qué esta pasando, cuando la base de datos es compleja y no está preparada «con cariño», puede ser difícil.
Aun así, creo que hay casos en los que puede resultar útil, por ejemplo con test de aprobación para protegerte de bugs de regresión. Si coges una base de datos de producción que, se supone, es correcta, puedes obtener unos cuantos informes, almacenar el resultado y usarlos en tests de caracterización. Cada vez que hagas cambios en la aplicación, puedes relanzar los tests contra esa base de datos y ver si algo ha cambiado. De esta forma «te ahorras entender» cómo está realmente la base de datos y si la aplicación funciona o no, simplemente, haces un acto de fe y confías en que es así. A partir de ahí, todo lo que pides que siga funcionando como lo hacía antes.
Por supuesto, para que esto funcione necesitarás un sistema automatizado que te permita recuperar el backup adecuado antes de ejecutar cada suite de tests.
Conclusiones
Crear una base de datos vacía con el esquema adecuado es (o debería ser) trivial hoy en día, pero inicializar una base de datos con información suficiente para ejecutar tests de integración puede ser una labor complicada o, al menos, tediosa.
En este post hemos visto varias alternativas, desde la más directa que nos lleva a insertar registro a registro lo que necesitamos para cada test, hasta la que permite usar datos más reales partiendo de imágenes de bases de datos de producción. Entre ellas, encontramos el uso de un ObjectMother
para disponer de algunos datos básicos que podamos utilizar en los tests, y la reutilización de un conjunto de datos controlado que ya estuviéramos usando para desarrollo o demos.
Utilizar una técnica u otra tiene implicaciones claras en cuanto a la legibilidad del test, la facilidad para entenderlo y lo acoplados que quedan unos tests con otros. Al final, es habitual mezclar todas estas técnicas dentro de una misma aplicación, aprovechando en cada momento las ventajas de cada una.
Como bien decía Daniel, donde suele aparecer el problema en este tipo de tests es en que mantener todos estos sistemas de generación de base de datos requiere tiempo y, si nuestra aplicación está sufriendo cambios profundos de forma continua, llega un punto en que resulta demasiado costoso.
Sin embargo, cuando la aplicación ha alcanzado un cierto grado de estabilidad funcional, al menos en determinadas áreas, ser capaz de prepara una base de datos razonable para ejecutar tests de integración, aporta mucho valor a la estrategia de testing.
Muy buenas,
yo lo primero que intento hacer es preguntarme para que quiero el test de integración. En mi caso intento utilizar de tres tipos:
1.- Tests de integración end to end para probar que todo esta bien cableado.
2.- Tests de integración para «guiar» mi diseño. Lo que sería nuestro punto de entrada en caso de hacer BDD, generalmente atacando al controlador o a la capa de justo después.
3.- Tests de integración para el repositorio.
En el primer caso, el setup de la base de datos me suele preocupar poco, ya que solo hago cosas muy simples. En el segundo caso, si lo puedo evitar no utilizo una versión de la base de datos e intento ir a memoria. Si el motor de la base de datos permite una implementación en memoria, pues mejor que mejor. O si el repositorio no es muy complejo, me implemento una versión en memoria del mismo.
En el tercer caso, que es un poco el que creo que comentas el artículo, no veo que haya una versión demasiado buena. Lo que suelo hacer es tener un metodo que se ejecuta una vez por libreria de test que carga los datos necesarios para todos los tests. Esto es algo que se puede hacer facilmente con EF por ejemplo, y sino hacerlo a mano tampoco cuesta mucho. En el proyecto que estoy ahora, cogemos el dacpac del proyecto de base de datos y lo deployamos en ese método y cargamos datos de prueba.
Como suele pasar, estos tests suelen ser bastante incordio porque pueden ser un tanto frágiles y, sobretodo, lentos.
Salut!