Hacía tiempo que no contaba nada sobre testing, pero en la última semana un par de conversaciones me han llevado a escribir este post sobre cómo testear un API Web. Aunque inicialmente lo escribí pensando más en WebAPI, en realidad son principios aplicables a cualquier tipo de API Web, sin importar con qué tecnología esté implementada, ni el tipo de API que sea (REST, RPC, SOAP, etc.).
OJO: como siempre, si llegas desde Google a este post y no me conoces, prefiero avisarte de antemano: no voy a hacer una guía paso a paso de cómo testear nada. Voy a contarte principios, estrategias e ideas sobre cómo puedes hacer las cosas. Soy de los que prefieren aprender despacio, así que si tienes prisa te recomiendo que dejes de leer aquí y evitarás sentirte defraudado luego.
Diseñando una estrategia de testing
Antes de empezar a escribir tests como locos, lo primero que tenemos que pensar es qué tipo de aplicación tenemos delante, qué partes queremos testear, por qué queremos testear esas partes y qué tipo de tests son los más adecuados para ello. Y no, decir que “quiero testearlo todo siguiendo una distribución de tests basada en la pirámide de los tests” no es una respuesta válida. Es una respuesta “de libro”. De no saber muy bien lo que se está haciendo y de limitarse a seguir indicaciones de otros sin tener muy claro por qué.
Digo esto porque existen muchos tipos de APIs Web y las funcionalidades que ofrecen varian mucho en complejidad. No es lo mismo un API que es un wrapper ligero sobre una base de datos (un CRUD camuflado de REST para que parezca que nuestra aplicación es moderna y guay), que un API que permite realizar cálculos complejos para optimizar el coste amortizado de utilizar distintos tipos de materiales en la fabricación de vehículos industriales.
Está claro que en esos dos escenarios mis necesidades son muy diferentes. En el caso del CRUD, no hay mucha lógica “de dominio” a testear y, probablemente, aparte del propio API me baste con testear el acceso a la base de datos con tests de integración y alguna regla de validación con tests unitarios. En el caso de los cálculos complejos, probablemente necesite bastantes más tests unitarios para comprobar el funcionamiento de una lógica mucho más compleja, protegerme de errores de regresión y dotarme de una red de seguridad que me permita refactorizar y añadir comportamiento cuando sea necesario.
¿Qué quiero decir con todo esto? Que para testear un API Web, lo primero que debes pensar en testear es todo aquello que no forma parte del API Web. La lógica de negocio. El dominio de tu aplicación. Y digo pensar en testear porque es eso, pensar. Puede que después de pensarlo decidas que no te merece la pena. Y no pasa nada.
La parte que decidas testear, te interesa separarla al máximo del API Web para poder testearla por separado usando la técnica que más te guste. Te interesa tener tu capa de API Web (controladores en el caso de WebAPI) lo más fina posible y que se encargue de llamar a tu lógica real, la cual habrás testeado por otro lado. Si decides que no hay nada interesante que testear, por ejemplo porque es un CRUD que usa un ORM del que te fías completamente y no hay demasiada complicación, puedes engordar tu capa de API Web sin mucho problema (al menos desde el punto de vista del testing).
Testeando la capa de API Web
Una vez que has testeado el resto de la aplicación, llega el momento de la verdad, ¿cómo testeo el API Web? Los frameworks modernos como WebAPI están concebidos desde el principio para que sea fácil testearlos con tests unitarios. Siguen los principios SOLID, tienen componentes muy pequeños, con responsabilidades muy bien definidas, interfaces e inyección de dependencias por todas partes. En definitiva, el paraíso de los tests unitarios.
Y, sin embargo, si estás usando un framework “moderno”, probablemente tu mejor opción sea usar algún tipo de test de integración.
Sí, en serio.
Pese a que los frameworks estén diseñados de forma que faciliten mucho escribir tests unitarios, en mi experiencia esos tests unitarios aportan muy poco.
Si lo has hecho bien y has aislado la lógica complicada lejos del framework, en los componentes relacionados con el framework debería quedarte muy poco por testear. Tendrás un puñado de componentes trabajando de forma coordinada: controladores, middlewares, filters, messageHandlers, binders, serializers, etc., muchos de ellos desarrollados por terceros y que “se supone” que funcionan, y los desarrollados por ti, seguramente con sólo un par de líneas de código para aplicar alguna responsabilidad de infraestructura.
Son componentes tan simples que, en general, es difícil (aunque no imposible) introducir un error al programarlos, y si lo introduces será fácil detectarlo y corregirlo. Además, por su tamaño, los tests no te van a servir de mucho si el componente evoluciona porque probablemente acabes reescribiendo a la vez el componente y los tests. Ni siquiera desde el punto de vista de diseño, si aplicas TDD, te van a ayudar mucho porque al implementar un interface ya definido por el framework, que tiene sólo un par de métodos y cada uno con tres líneas de código, no hay mucho que diseñar.
¿Dónde está la complejidad de toda esta capa? La complejidad reside en la configuración de los componentes. En asegurarte de que el filtro que se encarga de autenticar se ejecuta en el momento adecuado, que la serialización se realiza conforme a lo que esperas, que el middleware que genera los etags lo hace adecuadamente, que los atributos que usas para routing están bien puestos o que la nueva convención que has introducido en la tabla de rutas no ha roto nada.
Para poder comprobar todo esto, los tests de integración lanzando peticiones contra el API real son la mejor manera de obtener cierta seguridad de que la configuración es correcta y el sistema funciona como debe. Por supuesto, estos tests de integración no van a ser muy rápidos de ejecutarse y tampoco son muy cómodos si queremos testear lógica muy compleja. Por eso antes os decía que lo primero era separar toda la lógica compleja que tuviésemso y decidir cómo testearla por su lado.
Técnicas de testing
Si hemos decidido que queremos utilizar tests de integración, debemos ver de qué forma podemos escribirlos sin sufrir demasiado. Lo más complicado solía ser levantar el servidor con el API y preparar una base de datos limpia, pero hoy en día, con servidores tipo Katana o Kestrel y las posibilidades que brindan los ORMs, esto se ha simplificado bastante.
Una vez que eres capaz de levantar un servidor con un estado predecible de forma automática, sólo necesitas interactuar con él y validar el resultado. Parece fácil. Tienes varias opciones para esto.
Lo más directo sería atacar el servidor utilizando HttpClient
o cualquier cliente que te guste, ir lanzando operaciones contra él y realizar asserts
sobre las respuestas. Puedes aplicar algo similar al patrón Page Object para hacer los tests más legibles y reutilizar operaciones entre unos tests y otros. Lo malo es que escribir todos los tests resulta laborioso, sobre todo si tienes que construir grafos de objetos complejos para enviárselos al API y, si quieres validar la respuesta completa, acabas necesitando un montón de asserts
y es fácil que se te pase algo. Además, son muy buenos para garantizar que la respuesta contiene lo que queremos que contenga, pero no son tan buenos para comprobar que la respuesta contiene sólo lo que queremos que contenga.
Otra opción, que cada vez utilizo más, es emplear tests de aprobación. No voy a entrar en detalle sobre lo que son porque ya escribí un par de artículos explicando cómo funcionan los tests de aprobación, pero la idea básica es que tenemos un resultado “aprobado” por un humano y, cada vez que se ejecuta el test, obtenemos el nuevo resultado y lo comparamos con lo que había. Si es igual, el test pasa, y si es distinto, el test falla y solicitamos al humano que decida qué hacer, si aprobar el nuevo resultado o mantener el anterior y corregir el código.
Jugando con esta idea, podemos usar una herramienta como Fiddler para crear nuestras peticiones y almacenar las respuestas obtenidas. Posteriormente, podemos escribir test que lean los ficheros con las peticiones, los lancen contra el servidor, obtengan las respuestas y las comparen con las que tenemos guardadas.
Si aplicamos algún tipo de convención simple, por ejemplo llamar a los ficheros:
nombre_del_test.request.txt nombre_del_test.response.txt
Podemos escribir un test que se encargue de leer automáticamente los ficheros que hay en una carpeta y ejecutarlos como distintos casos de test. Esto nos permite añadir muy fácilmente un nuevo caso de test, sólo necesitamos crear el fichero desde Fiddler y guardarlo en esa carpeta para que, a partir de ese momento, se lance dentro de nuestra suite de pruebas automatizadas.
Esta opción funciona muy bien si tienes que probar interacciones simples. Si necesitas probar interacciones más complejas, del tipo de “después de invocar el API X, luego el Y y luego el Z, debería obtener el resultado A”, la cosa se complica un poco. Aunque puedes hacerlo mejorando la convención de nombres para añadir el concepto de pasos, con una nomenclatura como nombre_del_test.número_paso.request.txt
, tal vez merezca la pena ir por la vía de escribir un código más explícito para testearlo.
Conclusiones
Para testear cualquier sistema es importante intentar separar al máximo la lógica del resto de cosas. Eso nos permitirá focalizar el esfuerzo de testing en las partes en las que más valor puede aportar.
Cuando tenemos partes de un sistema en las que la infraestructura y la configuración juegan un papel importante, como es el caso de APIs Webs, con sus routers, binders, serializers, filters y demás elementos típicos de estos frameworks, los tests unitarios no suelen ser demasiado útiles y es mejor optar por tests de integración que nos ayuden a comprobar que todo el entramado de objetos está correctamente ensamblado.
Para poder escribir ese tipo de tests es fundamental que podamos automatizar el arranque del servidor desde cero y con un estado predecible. Después es cuestión de ejercitar el API de la misma forma que haría cualqueir otro cliente, o si podemos ir un paso más allá, montar un sistema de tests de aprobación que nos permita añadir fácilmente nuevos casos de tests.
Posts relacionados: